Compare commits

16 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
ShaoHua 2317c3c456 docs: 新增v1.3.0研发工单文档,清理旧文档引用 2026-06-15 23:24:02 +08:00
ShaoHua cf96c56bed chore: 完成v1.2版本迭代与代码清理
本次提交完成了多项清理与规范工作:
1. 移除默认管理员硬编码配置与云同步相关代码
2. 简化前端与MAUI端的配置,关闭静态资源托管以外的冗余功能
3. 清理.gitignore与协调目录,移除临时文件与冗余规则
4. 统一项目命名规范,修正包名与版本号
5. 重构后端数据模型,移除ABP审计字段与云同步相关逻辑
6. 简化WebView配置与系统栏样式,移除不必要的平台检测代码
7. 更新文档与规则文件,完善项目规范与版本记录
2026-06-15 22:06:58 +08:00
ShaoHua 46db04e43e fix(TaskItem,TaskList): 修复子任务嵌套层级丢失和渲染异常问题
1.  修复API返回平面子任务DTO时,直接替换任务丢失后代子任务的问题
2.  重构子任务创建逻辑,改用重新拉取全量任务树的方式解决深层嵌套任务的响应式渲染问题
3.  修改子任务添加方式为新数组引用赋值,确保Vue3可以正确检测嵌套层级的变更
4.  添加缺失的父任务未找到的警告日志和异常捕获处理
2026-06-14 05:17:20 +08:00
ShaoHua 14868c45c7 feat: 实现v1.2.0云同步与实体重构核心功能
1.  重构用户与任务实体:UserEntity实现IUser<Guid>,TaskEntity继承ABP风格FullAuditedEntityWithUser,主键从int改为Guid
2.  新增云同步代理系统:嵌入式WebServer支持CloudSyncProxy转发云同步请求,新增配置API与持久化
3.  完善前端适配:新增Guid工具函数,更新任务类型定义与API交互逻辑,调整云同步设置弹窗适配本地代理
4.  文档与配置优化:更新文档结构,新增部署文档、版本记录,统一各项目配置项
5.  补充测试与迁移:新增单元测试,更新EF Core数据库迁移快照
2026-06-14 04:51:22 +08:00
ShaoHua d81aa06681 refactor: 重构文档结构与环境配置,统一研发工单命名
1. 更新 .env 配置文件,替换原有云同步变量为 API 目标配置
2. 调整 publish-linux.ps1 中的文档路径,使用研发工单目录
3. 重构项目文档目录:将原 v1.2.0-tasks 迁移为研发工单-v1.2.0 目录,统一术语为"研发工单"替代"任务"
4. 更新 README.md 与各 PRD 文档的术语对照表,明确业务实体与研发工作项的区分
5. 新增多个研发工单文档,覆盖搜索、云同步、Linux 打包等模块
6. 删除旧的任务拆分文档,统一使用新的研发工单体系
2026-06-14 00:58:26 +08:00
ShaoHua a7ba814833 feat:云同步,部分功能生效 2026-06-12 00:49:03 +08:00
ShaoHua c54f2e2ecc feat:首次启动创建默认账户 2026-06-01 22:10:56 +08:00
ShaoHua 3dbd97103c feat:1.2.0初始版本未自测 2026-04-13 23:10:07 +08:00
ShaoHua 1f87565d5a 1.MAUI Android可以正常显示 2026-04-13 21:17:15 +08:00
ShaoHua d94d1f36d2 1.maui支持Android版本 2026-04-13 00:06:53 +08:00
ShaoHua d53828c150 feat: v1.2.0 开发进度更新
### 新增功能
- **Linux 官方支持**:新增 Hua.Todo.Avalonia 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化
- **SQLite DateTime 兼容修复**:新增 LenientUtcDateTimeStringConverter,解决历史遗留的 DateTime 脏数据解析问题
- **用户文档完善**:新增 docs/manual/新手指南.md 和 docs/manual/用户指南.md
- **部署文档**:新增 docs/manual/部署文档.md,详细说明多平台发布流程

### 优化与修复
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 publish.ps1 作为统一入口
- **Windows WebView2 优化**:数据目录调整到 %LocalAppData%\Hua.Todo\WebView2,修复 Runtime 误判问题
- **MAUI 多平台构建**:在 Windows 开发机上默认仅构建 Android + Windows 目标
- **SPA 路由回落**:修复 Release 模式下 /swagger 路径的 404 问题
- **Swagger 输出**:补齐 Dynamic API 端点,避免接口缺失

### 文档更新
- **版本记录**:更新 v1.2.0 开发进度和功能列表
- **技术设计文档**:添加 Avalonia 项目架构和模块设计
- **项目结构**:更新 README.md 中的项目结构说明

### 其他变更
- 新增 Directory.Build.props 和更新 Directory.Build.targets
- 调整 src/Hua.Todo.Avalonia 项目配置和资源文件
- 更新 src/Hua.Todo.Web 前端资源文件
- 修复 src/Hua.Todo.Maui 相关配置和打包脚本
2026-04-09 21:39:07 +08:00
288 changed files with 25645 additions and 3547 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+2 -4
View File
@@ -366,7 +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-shm
/src/Hua.Todo.Host/Hua.Todo.db-wal
/.artifacts/buildcheck
/.trae
src/Hua.Todo.Host/Hua.Todo.db-wal
src/Hua.Todo.Host/Hua.Todo.db-shm
+18
View File
@@ -0,0 +1,18 @@
# 协调目录(并行 solo 专用)
该目录用于解决两类问题:
- 文件冲突:多窗口并行时明确"谁是 Writer",其他人不直接改同一文件
- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进
## 目录约定
- `01-ownership.md`:文件/目录所有权登记(Writer 表)
- `02-shared-files.md`:本阶段共享文件清单(由 Integrator 维护)
- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘)
- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件)
## 使用规则
- 所有权与共享文件清单优先使用"仓库相对路径"
- 禁止记录或提交构建产物目录中的文件路径(如 `bin/``obj/``node_modules/``dist/` 等)
+14
View File
@@ -0,0 +1,14 @@
# 文件/目录所有权(Writer)登记
规则:
- 同一时段内,同一个文件只能有一个 Writer
- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘
- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码
## 当前所有权
> 状态:空(无进行中的并行任务)。新增并行任务时按下方表头格式追加记录行;任务验收后由 Integrator 清空。
| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 |
|---|---|---|---|
+11
View File
@@ -0,0 +1,11 @@
# 本阶段共享文件清单(Integrator 维护)
规则:
- 本文件只由 Integrator 修改,避免反复冲突
- 清单内每条必须是"仓库相对路径",并说明为什么共享(入口/协议/配置/依赖锁等)
- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator
## 共享文件
> 状态:空(无进行中的并行任务)。Integrator 在新阶段开始时按"路径:为什么共享"格式追加;阶段结束后清空。
+1
View File
@@ -0,0 +1 @@
{}
+28
View File
@@ -0,0 +1,28 @@
# 项目记忆(长期沉淀)
> 本文件保存**长期不变 / 不易频繁更新**的项目元信息。
> 当前实现进度、工单状态、未完结事项等"短期快照"请见 [.trae/rules/项目/04-即时状态记忆.md](../rules/项目/04-即时状态记忆.md)。
> 项目划分、依赖方向、运行模式见 [.trae/rules/项目/01-项目架构.md](../rules/项目/01-项目架构.md)。
## 关键依赖版本(含具体小版本号)
- **运行时**.NET 10.0SDK 10.0.201
- **C# 语言**C# 13
- **EF Core**10.0SQLite Provider
- **API 文档**Swashbuckle 10.1.7
- **前端**Vue 3 / TypeScript 5 / Vite 5Axios + Pinia
- **桌面**AvaloniaLinux/Windows/macOS
- **跨平台原生**MAUIAndroid/iOS/Windows/macOS
## 重要历史决策(不在即时状态记忆中重复记录)
- **.NET 10 适配**:针对 .NET 10 预览版特性(如 Windows TFM bug)进行规避与适配
- **Android 稳定性**:移除自动初始化 Provider,解决 AndroidX 相关崩溃
- **多平台构建开关**:通过 `Directory.Build.props` 优化非目标平台的构建依赖
- **跨平台热键平台拆分**Avalonia 与 MAUI 各自实现 `IGlobalHotKeyService`,平台目录分离
## 历史变更时间轴
- 2026-04-10:项目初始化与基础记忆建立
- 2026-04-13:升级至 v1.2.8,同步 CloudSync 与 DynamicApi 文档
- 2026-06:术语统一与目录中文化(详见 `04-即时状态记忆.md` § 五)
@@ -0,0 +1,82 @@
# 智能体记忆与存储规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(语义层面跨项目可复用),关注智能体如何维护记忆、存储与同步规范,与具体项目业务无关。
>
> `.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/全局/`
- **项目专属**(业务命名、架构边界、数据模型、即时状态)→ 写入 `.trae/rules/项目/`
- 若涉及到新的规范或规则,应创建新的规则文件进行记录
- 规范同步应及时、准确,确保规则文件能真实反映当前项目的编码规范和最佳实践
## 三、文件命名规则(强制)
`.trae/` 下所有子目录中**新增的文件必须沿用 `NN-名称.md` 序号格式**,否则视为不合规:
- **格式**:两位数字 + 连字符 + 中文/英文名称 + `.md`,例如 `06-XXX规范.md`
- **序号取值**:紧接当前目录已有最大序号 +1,不得跳号、不得重复
- **入口/索引文件例外**`.trae/索引.md` 这类目录入口文件不带序号
- **重排禁止**:除非整体重构,否则不得重排已有文件的序号;新增只能追加在末尾
- **同步更新索引**:每次新增文件后,必须在 [.trae/索引.md](../../索引.md) 的对应章节同步追加该文件的链接与一句话职责说明
- **跨目录创建**:在 `coordination/``memory/` 下新增文件时同样适用本规则
### 各子目录当前最大序号速查
| 子目录 | 当前最大序号 | 下一个可用 |
|---|---|---|
| `rules/全局/` | 07 | 08 |
| `rules/项目/` | 05 | 06 |
| `memory/` | 01 | 02 |
| `coordination/` | 02 | 03 |
## 四、实现要求
- 智能体应定期检查并更新规则文件,确保其与项目实际情况保持一致
- 当发现规范冲突或需要调整时,应及时记录并通知相关人员
- 记忆存储和规范同步应作为智能体的核心功能,贯穿于整个开发过程
## 五、路径规范
- 所有 Markdown 文档中不应使用绝对路径,应使用相对路径
- 相对路径应以项目根目录为基准,例如 `.trae/memory` 而非绝对路径
- 确保路径格式统一,使用正斜杠 (`/`) 作为路径分隔符,避免使用反斜杠 (`\`)
- 智能体在生成或修改文档时,应自动检查并替换绝对路径为相对路径
+36
View File
@@ -0,0 +1,36 @@
---
alwaysApply: true
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
---
# 注释规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用),针对 C# / TypeScript / Vue 代码的注释要求。
## 通用
- 新增或修改的代码必须包含足够注释,使"不了解该模块的人"也能理解其职责、边界与关键决策。
- 优先使用 **XML 文档注释**`///`),而不是随意的行内注释。
- 不允许无意义注释(例如"初始化变量""进入方法")。注释必须解释"为什么/约束/边界/副作用"。
- 不允许出现"TODO/FIXME"但无上下文或无处理方案的注释。
## C#.NET / MAUI
- 所有 `public` / `protected`**类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
- `summary`:一句话说明用途,不允许重复嵌套 `<summary>` 标签
- 对参数/返回值:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns`
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
- XML 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明,避免 `///` 落在特性之后导致 CS1587。
- `<see cref="..."/>` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574。
-**跨平台逻辑**
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
- 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。
-**异步/后台任务**
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
-**安全/隐私**
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
## TypeScript / Vue(前端)
- 对导出的函数/类型必须有注释,解释用途与输入输出。
- 对"与后端/MAUI 交互"的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
@@ -0,0 +1,38 @@
---
alwaysApply: true
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
---
# 文档同步规范(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
## 通用原则
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
- **协作友好(局部修改)**:当并行处理多个研发工单/需求时,更新文档应尽量只修改与本工单直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
> 术语澄清:本规范中"研发工单"指编码工作项;项目业务里的"任务/Todo 待办项"是用户域实体,二者不要混淆。详见 [04-研发工单全流程规范.md](./04-研发工单全流程规范.md)。
## 更新范围
- **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. [ ] 文档变更是否保持"局部修改",只影响与本工单相关的段落/小节?(避免无关重排/改写)
@@ -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)。
@@ -0,0 +1,115 @@
---
alwaysApply: false
description:
---
# 并行 solo 窗口冲突规约(必须遵守)
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
> 详见 [04-研发工单全流程规范.md](./04-研发工单全流程规范.md)。
> 凡涉及编码侧拆分时,**必须使用「研发工单」或「工单」**,禁止使用「任务」二字以避免与 Todo 待办项混淆。
## 适用范围
- 当同一个版本/需求被拆分为多个并行研发工单,并由多个 solo 窗口同时推进时适用。
- 目标是同时降低两类风险:
- **文件冲突**:多人同时改同一文件/相邻行导致冲突。
- **编译区间冲突**:A 窗口引入的未完成变更破坏编译,阻塞 B 窗口集成与验证。
## 核心原则
- **先声明后修改**:任何代码改动前,先在研发工单文档中声明"触碰文件清单(Touch List"与"共享文件策略"。
- **文件所有权唯一**:同一时段内,一个文件只能被一个窗口作为"写入者(Writer"修改。
- **共享文件单点修改**:涉及高耦合/共享入口的改动,集中到一个"集成窗口(Integrator)"完成,其他窗口只做准备工作(新文件/独立模块/文档/测试)。
- **绿线优先(可编译)**:任何可落盘、可合入的变更必须保持可编译;临时状态必须通过"隔离手段"而不是破坏编译来实现。
## Touch List(触碰文件清单)
- 每个并行研发工单 md 必须在开头包含一个明确的 Touch List,至少包含:
- 新增/修改/删除的文件路径(精确到文件,必须写"准确目录")
- 预期修改类型(新增/小改/重构/接口变更/配置变更)
- 是否为共享文件(是/否)
- Touch List 必须保持可检索与可更新:变更范围扩大时,必须先更新 Touch List 再改代码。
### 目录书写要求(必须遵守)
- Touch List 内每一条必须使用以下两种格式之一:
- **仓库相对路径(推荐)**`src\<module>\<file>`
- **绝对路径(可选)**`<repo-root>\src\<module>\<file>``<repo-root>` 为本机仓库根目录)
- Touch List 禁止包含构建产物与临时目录中的文件(这些文件不应被手工修改,且极易产生冲突),包括但不限于:
- `**\bin\**``**\obj\**`
- `**\node_modules\**`
- `**\.vite\**``**\dist\**`
### Touch List 模板(复制即可用)
- Touch List:
- `src\<module>\<file>`(共享:否|Writer:本窗口)
- `src\<module>\<file>`(共享:是|Writer<窗口名>
- `docs\<file>`(共享:是/否|Writer<窗口名>
- `.trae\<file>`(共享:是|Writer<窗口名>
## 文件所有权与共享文件策略
- **默认规则**Touch List 中标记为"共享文件"的条目,必须指定唯一 Writer。
- **Writer 约束**
- 非 Writer 窗口不得编辑该共享文件(包括格式化、重排 import、无关重构)。
- 需要对共享文件提出修改时,非 Writer 只能提供"差异建议"(文字说明/伪代码/小片段)交给 Writer 落盘。
- **共享文件判定(满足其一即为共享)**:
- 项目入口/启动逻辑、依赖注入注册、全局路由/导航、公共配置、公共协议与 DTO、公共组件/样式、跨模块公共工具
- 解决方案/项目文件(如 `.sln``.csproj`)、锁文件、全局配置文件(如 `appsettings*`、构建脚本)
## 协调目录(必须遵守)
- 为了让"文件冲突"和"编译中途状态"可操作、可对齐,仓库内必须固定保留一个专用协调目录:
- `.trae\coordination\`
- 该目录只用于协作对齐,不承载业务实现代码;多人可在不同文件中写入,避免互相踩踏。
- 并行推进时必须使用该目录中的文件记录"谁在改什么"和"中途状态怎么保证不破坏编译":
- `.trae\coordination\01-ownership.md`:文件/目录所有权(Writer)登记表
- `.trae\coordination\02-shared-files.md`:本阶段共享文件清单(只有 Integrator 维护)
- `.trae\coordination\handoff\`:非 Writer 提交的差异建议/交接说明(Integrator 落盘)
- `.trae\coordination\wip\`:编译中途状态说明(为什么需要隔离、如何保证绿线、何时收敛)
## 目录分区与低冲突写法
- 优先通过"新增文件"完成并行开发,减少在同一文件内的交错修改。
- 需要扩展既有逻辑时,优先选择低冲突策略:
- C#:新增类/partial 文件、扩展方法、接口实现分文件、平台目录分离
- TypeScript/Vue:新增模块/组件文件,避免在同一大文件内做多处改动
- 禁止在非必要情况下对共享文件做纯格式化、纯重排或无收益重构(这些改动高度易冲突且难以 review)。
## 编译绿线(避免编译区间冲突)
- **不得提交/合入破坏编译的变更**:包括缺失类型、未实现接口、引用不存在、配置缺项导致启动失败等。
- **允许的临时隔离手段(按优先级)**:
1. 新功能先放在新文件/新类中,不在入口路径上启用
2. 通过显式开关控制启用(配置/运行时开关),默认关闭
3. 通过依赖注入分支注册或特性开关隔离,默认不触发
- 当必须进行接口演进时,采用"双写/兼容期"策略:
- 先新增(保持旧接口可用)→ 再迁移调用方 → 最后清理旧接口
## 合入顺序与集成职责
- 每个并行阶段必须明确一个集成窗口(Integrator),负责:
- 处理共享文件的实际落盘与冲突消解
- 保持主干/集成分支持续可编译、可运行
- 其他窗口提交的成果应尽量以"新增文件 + 最小修改点"的方式交付,降低集成成本。
## 任务验收后 coordination 清理
研发工单验收完成、并行阶段结束后,**Integrator 必须**对 `.trae/coordination/` 做收尾清理:
- **清空记录行**:将 `01-ownership.md``02-shared-files.md` 中的运行时记录行删除,仅保留文件顶部说明、表头与示例占位行(让下一轮并行可以直接复用)
- **归档 handoff/wip**:删除已落盘消化掉的 `handoff/``wip/` 内容;如有需要长期沉淀的关键决策,迁移到 [.trae/memory/](../../memory) 对应文件中
- **不删除文件本身**`00-README.md``01-ownership.md``02-shared-files.md` 三个常驻文件保留,仅清空内容
- **冲突收尾确认**:清理前确保所有共享文件已合入主干、Touch List 已不再被任何窗口引用
- **同步项目状态**:在 [.trae/rules/项目/04-即时状态记忆.md](../项目/04-即时状态记忆.md) 的"工单状态快照"中将相关工单标记为已验证
## 最小检查清单
1. [ ] 每个研发工单 md 是否已写 Touch List(精确到文件)?
2. [ ] Touch List 中的共享文件是否指定了唯一 Writer?
3. [ ] 是否避免了对共享文件的无意义格式化/重排?
4. [ ] 当前改动是否保持可编译(绿线)?
5. [ ] 若涉及接口演进,是否采用兼容期策略而非一次性破坏式变更?
@@ -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/` 和即时状态记忆避免重复存储?
@@ -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)
+104
View File
@@ -0,0 +1,104 @@
# 项目架构(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 描述当前仓库的项目划分、依赖方向、运行模式与跨平台策略,供智能体在做改动时快速对齐架构边界。
> 完整设计参见 [docs/manual/05-技术栈与项目结构.md](../../../docs/manual/05-技术栈与项目结构.md) 与 [docs/manual/07-技术架构设计.md](../../../docs/manual/07-技术架构设计.md)。
## 一、项目清单(src/
| 项目 | 类型 | 职责 | 平台 |
|---|---|---|---|
| [Hua.Todo.Core](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Core) | 类库 | 领域实体、仓储接口(`TaskEntity``UserEntity``SecurityPolicyEntity``AuditLogEntity``ITaskRepository` 等) | netstandard / net |
| [Hua.Todo.Application](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application) | 类库(共享层) | EF Core `TodoDbContext``TaskService`/`TaskRepository`、动态 API`DynamicApi/`)、云同步(`CloudSync/`)、迁移 | net |
| [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host) | ASP.NET 服务端 | 独立服务端宿主,承载本地动态 API + 云同步端点 + Admin 管理后台静态资源 | Windows/Linux 等服务器 |
| [Hua.Todo.Maui](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Maui) | MAUI 客户端 | Windows / macOS / iOS / Android 入口;内嵌 WebServer + WebView 承载 Vue 前端 | 多端 |
| [Hua.Todo.Avalonia](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Avalonia) | Avalonia 客户端 | Linux / Windows 桌面入口;内嵌 WebServer + `WebView.Avalonia` 承载 Vue 前端 | 桌面(含 Linux |
| [Hua.Todo.Web](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Web) | Vite + Vue 3 前端 | 同一份前端构建产物,被多个宿主以静态资源形式承载 | 浏览器/WebView |
## 二、依赖方向(必须保持单向)
```
Hua.Todo.Core
Hua.Todo.Application
├── Hua.Todo.Host (服务端注册 AddCloudSyncServer + 动态 API)
├── Hua.Todo.Maui (客户端,仅注册 AddApplicationServices)
└── Hua.Todo.Avalonia (客户端,仅注册 AddApplicationServices)
Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主)
```
- **禁止反向依赖**Core 不得引用 ApplicationApplication 不得引用任何宿主项目。
- **客户端不暴露云同步端点**MAUI / Avalonia 只调用 `AddApplicationServices()`,不调用 `AddCloudSyncServer()`,详见 [docs/project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md](../../../docs/project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md)。
## 三、两种运行模式
### 模式 A:嵌入式(MAUI / Avalonia + WebView
- 启动内嵌 Kestrel WebServer(默认端口 5057),托管 Vue 前端的静态产物(`wwwroot/`+ 本地 `/api/*`
- WebView 加载 `HostUrl`(生产)或 `ForEndUrl`(开发,例如 Vite dev server
- 注入:`window.__API_BASE_URL__ = "${HostUrl}/api"``window.mauiInterop`
- 默认 SQLite 路径:`LocalApplicationData/Hua.Todo/Hua.Todo.db`(避免安装目录无写权限)
- 不暴露云同步端点;登录/同步走外部 Host
### 模式 B:独立服务端(Hua.Todo.Host
- 独立部署的 ASP.NET 应用,端口 `:5173`Vite proxy 目标)
- 同时注册 `AddApplicationServices()` + `AddCloudSyncServer()`,对外提供:
- `/api/*`:本地任务 API(动态 API
- `/auth/*``/tasks/*``/sync``/security/policy``/cloud-sync/probe`:云同步端点
- `/admin/*`:管理后台前端静态资源
- 数据库:`src/Hua.Todo.Host/Hua.Todo.db`(开发/测试用)
## 四、跨平台原则
- **业务逻辑统一在 Application 层**,宿主项目只做 DI 装配与平台桥接
- **平台差异通过接口 + 平台目录**实现(例:`IGlobalHotKeyService` 在 MAUI 与 Avalonia 各有自己的 `Platforms/` 子目录实现)
- **前端只有一份**:Vue 项目通过不同的 `.env.*` 文件区分模式(`.env.development` / `.env.maui` / `.env.production`
## 五、关键扩展点
| 扩展点 | 位置 |
|---|---|
| DI 注册总入口 | [Hua.Todo.Application/ServiceCollectionExtensions.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/ServiceCollectionExtensions.cs) |
| 云同步 DI | `Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs` |
| 云同步端点 | `Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs` |
| 动态 API 中间件 | `Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs` |
| 嵌入式 WebServer | `Hua.Todo.{Maui,Avalonia}/Services/EmbeddedWebServerService.cs` |
| 全局快捷键平台实现 | `Hua.Todo.{Maui,Avalonia}/Services/Platforms/*GlobalHotKeyService.cs` |
## 六、版本统一策略
- 全局版本号通过根目录 `Directory.Build.props` 集中管理(当前 `1.2.3`
- 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.{模块名}`
- 文件组织:每个模块新建子目录,模块级共用测试放根目录
@@ -0,0 +1,60 @@
# 业务命名规范(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 本文档规定 **业务实体**(Todo 待办项相关)与 **编码工作项**(研发工单)在代码、文档、提交信息中的命名边界。
> 全局术语规则参见 [.trae/rules/全局/04-研发工单全流程规范.md](../全局/04-研发工单全流程规范.md)。
## 一、术语对照(核心)
| 概念 | 含义 | 代码标识符 | 文档用语 |
|---|---|---|---|
| **Todo 待办项 / 业务任务** | 用户在 Hua.Todo 中创建的待办事项 | `Task` / `SubTask` / `TaskEntity` / `TaskItem` / `TaskService` / `TaskRepository` | "Todo 待办项 / 任务 / 子任务(业务实体)" |
| **研发工单(Dev Work Item** | 智能体/开发者执行的编码工作项 | 无(仅文档层概念) | "研发工单 / 工单 / 子工单" |
## 二、代码命名约定(已固化,不要改名)
> 以下标识符已落入 API 契约、数据库表、前端类型,**禁止以"统一术语"为由进行重命名**。
### 2.1 .NET / C# 侧
- 实体:`TaskEntity`(位于 [Hua.Todo.Core/Entities/TaskEntity.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Core/Entities/TaskEntity.cs)
- 字段:`ParentTaskId`(父子任务关系)、`Priority``UserId`
- 服务:`ITaskService` / `TaskService``ITaskRepository` / `TaskRepository`
- 云同步 DTO`CloudTaskItem`(位于 `Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs`
- 用户固定 ID`TodoUserIds.LocalUserId = "local"`(见 `Hua.Todo.Core/Entities/TodoUserIds.cs`
### 2.2 前端(Vue/TS)侧
- 类型:`TaskItem` / `TaskNode`(位于 [Hua.Todo.Web/src/types/task.ts](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Web/src/types/task.ts)
- 组件:`TaskList.vue` / `TaskItem.vue` / `TaskEditDialog.vue`
- API 模块:`api/tasks.ts``api/cloudSync.ts`
### 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/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`
- 迁移文件位于 `Hua.Todo.Application/Migrations/`
## 三、新增代码命名指引
### 3.1 与 Todo 业务相关的新代码
- 沿用 `Task` / `SubTask` 词根,与既有命名保持一致
- 如:`TaskFilterService``SubTaskCounter``TaskExportDto`
### 3.2 与研发工单/工程基础设施相关的新代码
- 不要使用 `Task` 词根(避免与业务实体撞名)
- 如果是 .NET 异步方法,可使用 `Async` 后缀但不要把方法/类型主体命名为 `Task`
### 3.3 文档与提交信息
- 业务变更:`feat(todo): 新增 Todo 待办项截止日期字段`
- 工程变更:`chore(workitem): 拆分 v1.2.0 研发工单 02-后端模型`
- 严禁含糊措辞:`feat: 完成任务 02`"任务"歧义)
## 四、命名一致性检查清单
1. [ ] 新增类/接口是否复用了已有 `Task*` 命名习惯?
2. [ ] HTTP 路由是否与既有 `/api/task` / `/tasks` 风格一致?
3. [ ] 文档段落是否在首次出现"任务"时明确指向 Todo 待办项还是研发工单?
4. [ ] 提交信息是否避免了歧义"任务"用法?
@@ -0,0 +1,99 @@
# 数据模型与迁移约束(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 描述当前数据库实体、字段约束、EF Core 迁移历史,约束智能体在改 schema 时遵循的纪律。
## 一、技术栈
- ORM**EF Core**
- 数据库:**SQLite**(嵌入式与 Host 开发都走 SQLite;生产 Host 可切其他后端)
- DbContext[TodoDbContext](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/Data/TodoDbContext.cs)
- 迁移目录:`Hua.Todo.Application/Migrations/`
- **表名规范**:遵循 ABP 模板规范,格式为 `T_{实体名}s`,如 `T_Tasks``T_Users`
## 二、当前实体清单(src/Hua.Todo.Core/Entities/
### 2.1 TaskEntity(待重构为 ABP 基类)
> ⚠️ **重大变更预警**TaskEntity 计划继承 `FullAuditedEntityWithUser<Guid, IdentityUser>`,重构后将包含 ABP 全部审计字段。此改为破坏性变更,详见 [09-CloudSync-同步策略改进方案.md](../../../docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md)。
**重构前(当前)**
| 字段 | 类型 | 说明 |
|---|---|---|
| `Id` | `int` | 主键(待改为 Guid |
| `UserId` | `Guid` | 任务所属用户 |
| `Title` | `string` | 标题 |
| `Priority` | `TaskPriority` | 优先级枚举 |
| `IsCompleted` | `bool` | 是否完成 |
| `CreatedAt` | `DateTime` | 创建时间 |
| `UpdatedAt` | `DateTime` | 更新时间 |
| `ParentTaskId` | `int?` | 父任务ID(待改为 Guid? |
**重构后(ABP 标准)**
继承 `FullAuditedEntityWithUser<Guid, IdentityUser>` 后自动获得:
| ABP 审计字段 | 类型 | 说明 |
|---|---|---|
| `Id` | `Guid` | 主键(强制 Guid |
| `ExtraProperties` | `ExtraPropertyDictionary` | 扩展属性(基类提供) |
| `ConcurrencyStamp` | `string?` | 并发戳 |
| `CreationTime` | `DateTime` | 创建时间 |
| `CreatorId` | `Guid?` | 创建人 |
| `LastModificationTime` | `DateTime?` | 最后修改时间 |
| `LastModifierId` | `Guid?` | 最后修改人 |
| `IsDeleted` | `bool` | 软删除标记 |
| `DeletionTime` | `DateTime?` | 删除时间 |
| `DeleterId` | `Guid?` | 删除人 |
业务字段保留:`UserId``Title``Priority``IsCompleted``ParentTaskId`
### 2.2 其他实体
| 实体 | 关键字段 | 约束 |
|---|---|---|
| `TaskPriority` | 枚举 | Priority 字段对应类型 |
| `UserEntity` | `Id``UserName``PasswordHash``PasswordSalt``Role``MustChangePassword` | 唯一索引:`UserName` |
| `UserSessionEntity` | `SessionId``UserId``ExpiresAtUtc``IsStepUp``StepUpExpiresAtUtc` | session token 由 DB 管理(非纯 JWT |
| `SecurityPolicyEntity` | `UserId``AllowPersist``AllowSync``SecondFactorExpiryMinutes``IsTrustedDeviceOnly` | 与 User 一对一 |
| `AuditLogEntity` | `Id``UserId``Action``OccurredAtUtc``Details` | 关键安全事件 |
| `TodoUserIds` | `LocalUserId = "local"` | 静态常量类,非实体 |
## 三、迁移历史(按时间)
| 迁移 | 含义 |
|---|---|
| `20260313044926_InitialCreate` | 初始 Tasks 表 |
| `20260313092658_AddParentTaskId` | 父子任务字段 |
| `20260406172936_AddCloudSyncCoreEntities` | Users / UserSessions / SecurityPolicies |
| `20260406173734_AddAllowSyncToSecurityPolicy` | `AllowSync` 字段 |
| `20260413140347_UpdateSecurityEntities` | 安全实体调整 |
| `20260413140753_AddAuditLogs` | AuditLogs 表 |
| `20260424164713_AddPasswordSaltToUsers` | 密码加盐 |
| `20260510171230_AddMustChangePasswordToUsers` | 强制改密标志 |
| `MakeTaskEntityAbpCompatible`(待创建) | 重构为继承 ABP 基类,新增审计字段,主键从 `int` 改为 `Guid` |
## 四、改 schema 必须遵守的纪律
1. **禁止手改 `*ModelSnapshot.cs`**:使用 `dotnet ef migrations add` 命令生成
2. **迁移命名以动词开头**`AddXxx` / `UpdateXxx` / `RemoveXxx` / `RenameXxx`
3. **避免破坏性迁移**:删除字段前先确认数据已被业务层迁移;优先采用"双写/兼容期"
4. **嵌入式宿主启动时自动 Migrate**:见 `EmbeddedWebServerService.cs` 中的 `db.Database.Migrate()` 调用;任何破坏迁移幂等性的改动都会让客户端启动失败
5. **业务字段命名沿用 `Task*` 词根**(即业务实体),编码工作侧不要在实体上用 `Task` 词根之外的同义词
6. **`UserId` 字段不可为空**:本地模式使用 `TodoUserIds.LocalUserId = "local"`,云端模式使用真实用户 GUID 字符串
## 五、DTO 与实体的映射边界
- **实体(Entity**:仅在 `Hua.Todo.Core` / `Hua.Todo.Application/Data` 内部使用
- **DTOModels**:跨进程边界(HTTP API、WebView 注入)使用,位于 `Hua.Todo.Application/CloudSync/Models/``Hua.Todo.Application/Models/`
- **不要把实体直接序列化为 API 响应**:避免暴露内部字段、避免循环引用
## 六、检查清单(schema 变更)
1. [ ] 是否新增了 EF Core 迁移(而非手改快照)?
2. [ ] 迁移名称是否以 `AddXxx`/`UpdateXxx`/`RemoveXxx` 开头?
3. [ ] 是否在 [docs/manual/07-技术架构设计.md](../../../docs/manual/07-技术架构设计.md) 中同步更新数据模型描述?
4. [ ] 是否在 [docs/manual/03-版本记录.md](../../../docs/manual/03-版本记录.md) 中追加非琐碎变更条目?
5. [ ] 是否在嵌入式宿主上验证了启动时 Migrate 不报错?
+157
View File
@@ -0,0 +1,157 @@
# 即时状态记忆(Hua.Todo 专属)
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
>
> 本文档保存"当前实现到哪一步、未完结事项、临时决策"等**短期快照**,用于跨会话/跨智能体快速对齐。
> 与 [.trae/memory/](../../memory) 的差别:memory 偏长期沉淀,此处偏即时状态,更新频率高。
>
> **维护要求**:智能体每完成一个研发工单或观察到状态变化时,必须更新本文件的对应章节。
---
## 一、当前活跃版本
- **进行中版本**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 工单状态快照
| 子工单 | 实现状态 | 验证状态 | 简要说明 |
|---|---|---|---|
| 01 - Linux Avalonia 入口 + WebView | 已完成 | 待验证 | `WebView.Avalonia` + `WebView.Avalonia.Desktop`Linux 依赖 GTK + WebKitGTK |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `publish-linux.ps1``.tar.gz`Flatpak 基础结构在 `pack/linux/` |
| 02.1 - 版本统一打包 | 已落地 | 待验证 | `Directory.Build.props` 统一版本;Avalonia 新增 `setup.iss` |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 主界面搜索框,按 Todo 标题包含匹配;Esc 清空;英文不分大小写 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约固化;`Tasks` 表加 `UserId` 隔离;RBAC + step-up |
| 05 - 云同步 客户端配置/工作流 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址保存探测、登录/登出、登录后只读展示云端 Todo |
| 06 - 安全与可控落盘 | 未标注 | 待验证 | 框架就绪(`SecurityPolicy`),客户端落盘策略尚未充分覆盖 |
| 06.1 - 服务端安全设计 | 已设计 | 待实现 | Argon2id/JWT/审计日志/Admin 管理后台规划 |
| 07 - 文档同步与验收 | 进行中 | 进行中 | README/docs 已基本对齐;术语对照刚完成 |
| 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 实现
- [ ] 08 同源 Host 改造:`cloudClient.ts` 的 baseURL 解耦、Vite proxy 端点补全
- [ ] 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/` 全部中文文件名 + 拆分为 `全局/``项目/` 两个子目录
- `docs/project/v1.2.0-tasks/``docs/project/研发工单-v1.2.0/``00-任务总览.md``00-工单总览.md`
- 在 PRD 顶部加入"研发工单 vs Todo 待办项"术语对照表
- 业务代码 `Task`/`SubTask` 标识符**保持不动**(已固化于 API、DB、UI)
- **`.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) 的第七章"与其他工单的语音入口衔接"。
+76
View File
@@ -0,0 +1,76 @@
# .trae 目录索引
> 本文件是 `.trae/` 目录的总入口,描述各子目录与关键文件的职责,便于智能体与开发者快速定位。
>
> 关于 TRAE 官方"全局规则 vs 项目规则"TRAE IDE 的"全局规则"由设置中心保存到用户级、**不入仓**;本仓库 `.trae/rules/` 下的所有内容(含子目录)都会被 TRAE 识别为**项目规则**。下面的子目录 `全局/` 与 `项目/` 是项目内部的**语义分组**,不改变 TRAE 的加载语义。
>
> 子目录嵌套深度受 TRAE 官方限制:`.trae/rules/` 下最多 3 层嵌套。
>
> 文件命名约定:每个子目录内文件以 `NN-名称.md` 格式编号(两位数字),编号反映**阅读优先级 / 依赖顺序**。索引文件本身不带序号。
>
> ⚠️ **新增文件必须遵守命名规则**:序号 = 当前目录最大值 + 1,不得跳号或重复,并需同步更新本索引文件中的对应章节。详见 [01-记忆与存储规范.md](./rules/全局/01-记忆与存储规范.md#三文件命名规则强制)。
>
> ⚠️ **任务完成后清理义务**:研发工单验收完成后,必须清理 `coordination/` 中的临时记录行(保留文件与表头),并按需追加 `memory/` 的长期记忆条目。详见 [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md#任务验收后-coordination-清理) 与 [01-记忆与存储规范.md](./rules/全局/01-记忆与存储规范.md#14-memory-生命周期)。
---
## 整体结构
```
.trae/
├── 索引.md ← 本文件(目录入口)
├── rules/
│ ├── 全局/ ← 通用规范(跨项目可复用的方法论)
│ └── 项目/ ← Hua.Todo 专属(业务/架构/状态/数据)
├── memory/ ← 长期记忆数据(决策、对话产物)
└── coordination/ ← 多 solo 窗口协作目录(运行时登记表)
```
---
## rules/全局/ — 通用规范(与具体项目无关)
| 文件 | 职责 |
|---|---|
| [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) |
---
## rules/项目/ — Hua.Todo 专属
| 文件 | 职责 |
|---|---|
| [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) | 测试项目目录结构、分层规则(禁止跨层)、技术栈与命名规范 |
---
## memory/ — 长期记忆
- 用途:长期沉淀的对话产物、关键开发决策、不再频繁更新的项目快照
- 与"项目即时状态"的边界:[04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) 偏当前快照、更新频率高;`memory/` 偏长期保留
- 维护规约:详见 [01-记忆与存储规范.md](./rules/全局/01-记忆与存储规范.md)
- 文件:
- [01-project_memory.md](./memory/01-project_memory.md):依赖小版本号、历史决策、变更时间轴
---
## coordination/ — 多窗口协作运行时目录
- 用途:并行 solo 窗口下的"谁在改什么"登记表与共享文件清单(**运行时数据,非冷文档**)
- 协作协议详见 [05-并行窗口冲突规约.md](./rules/全局/05-并行窗口冲突规约.md)
- 文件:
- [00-README.md](./coordination/00-README.md):目录约定
- [01-ownership.md](./coordination/01-ownership.md):文件/目录所有权(Writer)登记
- [02-shared-files.md](./coordination/02-shared-files.md):本阶段共享文件清单(Integrator 维护)
- `handoff/``wip/`:差异建议交接 / 编译中途状态(按需新建)
+2
View File
@@ -20,6 +20,8 @@
"args": [
"publish",
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
"-c",
"Release",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
+28
View File
@@ -0,0 +1,28 @@
<Project>
<PropertyGroup>
<!-- 统一版本号 -->
<Version>1.2.8</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- 通用元数据 -->
<Authors>ShaoHua</Authors>
<Company>Hua.Todo</Company>
<Product>Hua.Todo</Product>
<Copyright>Copyright © 2024 ShaoHua</Copyright>
<Description>A simple cross-platform Todo application.</Description>
<!-- 编译优化选项 -->
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现"文件正由另一进程使用" (XARDF7024) 的问题。 -->
<MSBuildDisableNodeReuse>true</MSBuildDisableNodeReuse>
<!-- 禁止 SDK 自动生成 AssemblyInfo,避免与 Directory.Build.props 中的元数据属性冲突(CS0579)。 -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
+3 -3
View File
@@ -1,8 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True)">true</UseMonoRuntime>
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RIDwin-*/linux-*/osx-*)时强制禁用,避免还原阶段解析对应的 Mono runtime pack。 -->
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')))">false</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')) != True))">true</UseMonoRuntime>
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
</PropertyGroup>
</Project>
+15 -2
View File
@@ -5,13 +5,26 @@
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Folder Name="/src/" />
<Folder Name="/src/Domain/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj" />
<Project Path="src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.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>
+23 -43
View File
@@ -1,17 +1,16 @@
# Hua.Todo 跨平台代办管理应用
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
一个基于 MAUI + WebView 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **跨平台支持**:基于 MAUI + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示)
## 📦 安装与使用
@@ -38,7 +37,6 @@ dotnet restore
dotnet run
```
API 将在 `http://localhost:5173` 启动
开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`
#### 3. 启动前端 Web
```bash
@@ -48,27 +46,6 @@ npm run dev
```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
#### 4. 启动 MAUI 客户端(Windows 三件套开发)
- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)``Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。
- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI):
```powershell
.\start-dev.ps1
```
### Windows 交付产物(安装包)
- 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj``<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows`publish.ps1 -Windows`
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件)
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json``WebServer.IsUsingStatic=true`
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件)
### Linux 交付产物(v1.2.0
- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux`
- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/`
### 使用说明
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
@@ -80,7 +57,6 @@ npm run dev
### 项目结构
```
Hua.Todo/
├── pack/ # 打包与交付产物(Linux/安装包等)
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ └── project/ # 项目进度/需求文档
@@ -90,28 +66,24 @@ Hua.Todo/
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux)
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档
```
### API 端点
- `GET /api/task` - 获取任务列表(默认:全部)
- `GET /api/task/active` - 获取未完成任务
- `GET /api/task/completed` - 获取已完成任务
- `GET /api/task/{id}` - 获取单个任务
- `POST /api/task` - 创建任务
- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位)
- `PATCH /api/task/{id}/toggle` - 切换完成状态
- `DELETE /api/task/{id}` - 删除任务
- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表
- `GET /api/tasks` - 获取任务列表
- `GET /api/tasks/{id}` - 获取单个任务
- `POST /api/tasks` - 创建任务
- `PUT /api/tasks/{id}` - 更新任务
- `PATCH /api/tasks/{id}/complete` - 切换完成状态
- `DELETE /api/tasks/{id}` - 删除任务
## 🤝 交流与贡献
- **QQ 交流群**2167048911 (Hua.Todo 交流群)
- **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/04-其他信息.md)
## 📄 开源协议
@@ -119,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)
+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)。
+70
View File
@@ -0,0 +1,70 @@
# 版本更新历史
## 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
- 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)
- **文档**:新增 [云同步规则](./08-云同步规则.md),汇总 Todo 待办项云同步的架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等完整规则。
### v1.2.8 (2026-04-13)
- **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。
- **动态 API 增强**:完善 `DynamicApi` 逻辑,支持 Swagger 自动过滤与中间件拦截。
- **代码规范同步**:强制执行 XML 文档注释与跨平台逻辑分离规范,更新 `.trae/rules` 规则库。
- **多平台构建优化**:优化 `Directory.Build.props``Directory.Build.targets`,精细化控制各平台(Windows/Android/iOS/Linux)的构建开关与依赖。
- **版本号统一**:全项目版本号提升至 `v1.2.8`,同步更新各平台安装包与发布脚本。
### v1.2.02026-04-07
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用"命中即显示(含上下文)"策略;支持 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。
- **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 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
- **文档与部署指南**:新增部署文档,详细说明开发环境搭建、多平台发布流程(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 中"云同步"需求。
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持 (Windows, macOS, Android, iOS)
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
- 实现子任务支持
### v1.0.0 初始版本
- 初始 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)
+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 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
+512
View File
@@ -0,0 +1,512 @@
# Todo 待办项云同步规则
> 本文档面向开发者,汇总 Hua.Todo 项目的云同步完整规则,涵盖架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等。
>
> 术语:本文中"任务 / Todo 待办项"均为业务实体(对应代码 `Task` / `SubTask` / `TaskEntity`),与编码侧的"研发工单"无关。
---
## 一、部署架构与职责边界
### 1.1 两种运行模式
| 模式 | 宿主 | 云同步端点 | 说明 |
|---|---|---|---|
| **嵌入式** | MAUI / Avalonia | 不暴露 | 嵌入 Kestrel + WebView,仅注册 `AddApplicationServices()`,托管本地 `/api/*`;云同步需连接外部 Host |
| **独立服务端** | Hua.Todo.Host | 完整暴露 | 同时注册 `AddApplicationServices()` + `AddCloudSyncServer()`,对外提供全部云同步端点 |
### 1.2 DI 注册边界(强制)
```
Hua.Todo.Application (共享层)
├── AddApplicationServices() ← Todo CRUD,两端均注册
│ ├── TodoDbContext / TaskRepository / TaskService
│ └── DynamicApi (本地 /api/*)
└── AddCloudSyncServer() ← 云同步能力,仅 Host 注册
├── CloudAuthService / CloudTaskSyncService
├── SecurityPolicyService / CloudAdminService / CloudProbeService
├── SessionAuthenticationHandler (Bearer Token 鉴权)
└── MapCloudSyncEndpoints() (13 个云同步端点)
```
- **MAUI / Avalonia**`MauiProgram.cs` / `AvaloniaProgram.cs` **只能**调用 `AddApplicationServices()`,禁止调用 `AddCloudSyncServer()`
- **Hua.Todo.Host**`Program.cs` 同时调用两者。
### 1.3 同源 Host 改造(研发工单 08)
前端云同步请求统一走当前 Host 同源,不再直连外部 `serverUrl`
- **Host 模式(Vite dev**Vite proxy 将 `/auth``/tasks``/sync``/security``/cloud-sync` 转发到 Host`:5173`)。
- **MAUI 模式(WebView**:Vue 静态部署到嵌入服务器同源,无需代理。
- `cloudClient.ts` 不设外部 baseURL,由浏览器自动同源。
- 用户配置的 `serverUrl` 仅用于服务端代理探测(`POST /cloud-sync/probe`),不再作为 API 基址。
---
## 二、数据隔离规则
### 2.1 UserId 隔离(强制)
- `Tasks` 表含 `UserId` 字段(外键),所有云同步 API 按当前登录用户隔离读写。
- 本地模式(嵌入式):`Tasks.UserId = TodoUserIds.LocalUserId = "local"`,与云端用户共存不冲突。
- 服务端查询/写入必须从会话上下文提取 `UserId`,不得接受客户端传入的 `UserId` 参数。
### 2.2 同步数据范围
- 每次同步操作仅涉及**当前登录用户**的 Todo 待办项。
- `GET /tasks` 返回该用户全量任务(`CloudTaskItem[]`)。
- `POST /sync` 的 upsert/deletes 均限定在当前用户数据范围内执行。
---
## 三、认证与会话管理
### 3.1 认证方式
- **Bearer Token**:所有云同步端点需携带 `Authorization: Bearer {accessToken}`
- Token 即 `UserSessionEntity.SessionId`GUID),存储于服务端 `UserSessions` 表,非纯无状态 JWT。
### 3.2 会话生命周期
| 操作 | 端点 | 说明 |
|---|---|---|
| 初始化管理员 | `POST /auth/bootstrap` | 仅当系统无云用户时可调用一次,自动生成随机密码 |
| 登录 | `POST /auth/login` | 验证用户名/密码(Argon2id 哈希),创建 `UserSessionEntity`,返回 AccessToken + 权限列表 |
| 登出 | `POST /auth/logout` | 删除当前会话记录,Token 立即失效 |
| 修改密码 | `POST /auth/change-password` | 需提供当前密码,更新后旧会话不失效 |
### 3.3 SessionAuthenticationHandler 鉴权流程
1.`Authorization` 头提取 Bearer TokenGUID 格式 SessionId
2.`UserSessions` 表:校验 SessionId 存在且 `ExpiresAtUtc > DateTime.UtcNow`
3. 加载用户角色,通过 `IRolePermissionMapper` 获取权限列表
4. 检查 `SecurityPolicies.AllowSync`:若为 `false`,从权限中移除 `sync:write`
5. 构造 `ClaimsIdentity`(含 `sub`=UserId、`role``perm` Claims),注入 `HttpContext.User`
---
## 四、RBAC 权限模型
### 4.1 权限点定义(6 个)
| 权限点 | 常量 | 说明 |
|---|---|---|
| `tasks:read` | CloudPermissions.TasksRead | 读取 Todo 待办项 |
| `tasks:write` | CloudPermissions.TasksWrite | 写入 Todo 待办项 |
| `sync:write` | CloudPermissions.SyncWrite | 执行同步操作(受 `AllowSync` 策略叠加限制) |
| `policy:read` | CloudPermissions.PolicyRead | 读取安全策略 |
| `policy:write` | CloudPermissions.PolicyWrite | 修改安全策略 |
| `users:manage` | CloudPermissions.UsersManage | 管理用户(Admin 专属) |
### 4.2 角色与权限映射
| 角色 | 权限 |
|---|---|
| `admin` | 全部 6 个权限 |
| `user`(默认) | `tasks:read``tasks:write``sync:write``policy:read` |
| `readonly` | `tasks:read``policy:read` |
| `nosync` | `tasks:read``tasks:write``policy:read` |
> `sync:write` 权限在鉴权时还会受 `SecurityPolicies.AllowSync` 二次过滤:若策略禁止同步,即使角色拥有 `sync:write` 也会被移除。
### 4.3 端点权限要求
| 端点 | 方法 | 所需权限 |
|---|---|---|
| `/tasks` | GET | `tasks:read` |
| `/sync` | POST | `sync:write` |
| `/security/policy` | GET | `policy:read` |
| `/security/policy` | PUT | `policy:write` |
| `/admin/*` | GET/POST/DELETE | `users:manage` |
| `/cloud-sync/probe` | POST | —(匿名访问) |
---
## 五、安全策略与可控落盘
### 5.1 SecurityPolicies 表
| 字段 | 类型 | 说明 |
|---|---|---|
| `AllowPersist` | bool | 是否允许客户端持久化 Todo 数据到本地存储 |
| `AllowSync` | bool | 是否允许该用户执行同步写入 |
| `IsTrustedDeviceOnly` | bool | 是否仅限受信任终端(预留) |
每个用户一条策略记录,与 `UserEntity` 一对一。
### 5.2 客户端落盘规则(强制)
| 服务端策略 | 客户端行为 |
|---|---|
| `allowPersist = true` | 正常落盘:Todo 数据 + 登录凭据(Token)写入 `localStorage` / SQLite |
| `allowPersist = false` | **内存模式**:Todo 数据与凭据仅保留在内存中,退出应用后全部清除 |
**策略切换时的处理**
- `true → false`:立即清空已落盘数据,提示用户"已切换为内存模式,退出后数据不保留"
- `false → true`:恢复持久化,将当前内存数据写入存储
### 5.3 落盘数据范围
"禁止落盘"时,以下数据均不得写入任何持久化介质:
- Todo 数据(列表/详情)
- 登录凭据(accessToken / 会话标识)
- 同步状态(上次同步时间、待同步队列)
- 安全策略缓存
---
## 六、同步工作流
### 6.1 完整流程
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 配置地址 │ ──→ │ 登录 │ ──→ │ 拉取全量 │
│ (探测可达) │ │ (获取Token) │ │ (GET /tasks) │
└──────────────┘ └──────────────┘ └──────┬───────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 返回全量 │ ←── │ 增删改同步 │ ←── │ 本地编辑 │
│ (SyncResponse)│ │ (POST /sync) │ │ (upsert+del)│
└──────────────┘ └──────────────┘ └──────────────┘
```
### 6.2 同步策略
#### 6.2.1 核心原则
- **任务唯一标识是 `id`**`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
- **`updatedAtUtc` 是冲突判断字段**:用于解决"同一任务被多端修改时哪个变更更新"的问题
- **服务端存储唯一真实源(Source of Truth)**:所有设备最终都收敛到服务端数据
- **字段级 Last-Write-WinsLWW)合并**:同一字段的多设备并发修改,以 `updatedAtUtc` 较新者为准
- **逻辑删除(Tombstone**:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致
- **增量同步**:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量
#### 6.2.2 变更追踪机制
每个 `CloudTaskItem` 包含以下时间戳字段:
| 字段 | 说明 |
|---|---|
| `createdAtUtc` | 创建时间(服务端分配,永不改变) |
| `updatedAtUtc` | 最后一次修改时间(每次字段变更时更新) |
| `deletedAtUtc` | 逻辑删除时间(为 `null` 表示未删除) |
> `updatedAtUtc` 由**请求发起方**(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。
#### 6.2.3 冲突解决规则
当客户端提交的任务与服务端现有任务发生 `id` 冲突时,按以下规则处理:
| 场景 | 解决规则 |
|---|---|
| 同一 `id`,客户端 `updatedAtUtc` **更新** | 服务端接受客户端版本,覆盖对应字段 |
| 同一 `id`,服务端 `updatedAtUtc` **更新** | 服务端拒绝客户端版本,保留服务端版本 |
| 同一 `id`,两者 `updatedAtUtc` **相同** | 服务端优先(保守策略) |
**字段级合并示例**
- 设备 A 修改标题,设备 B 修改截止日期
- 服务端对两个字段分别取 `updatedAtUtc` 较新者,合并出最终结果
- 两个设备的修改都被保留,不会互相覆盖
#### 6.2.4 父子任务处理
- **删除**:删除父任务时,其子任务一并标记为已删除(递归)
- **创建**:子任务的 `parentTaskId` 在上传时若为 `null`(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射
- **重建父子关系**:若原父任务被删除后重建,子任务的 `parentTaskId` 引用仍指向原父 ID,不会自动指向新父
#### 6.2.5 Tombstone 保留策略
- `deletedAtUtc``null` 的任务在服务端保留至少 **30 天**
- 超过 30 天后由服务端垃圾收集(物理删除)
- 客户端同步时拉取全量(含 Tombstone),本地过滤不展示 `deletedAtUtc != null` 的任务
#### 6.2.6 服务端处理流程
`POST /sync` 服务端执行顺序:
```
1. 解析 upserts 和 deletes
2. 对 deletes 中的每个 id
- 递归标记该任务及所有子任务 deletedAtUtc = now
3. 对 upserts 按 updatedAtUtc 降序排列(较新的先处理)
4. 对每个 upsert 任务:
a. 若 id 在服务端不存在 → 创建新记录(分配正向 id)
b. 若 id 存在但服务端 updatedAtUtc 更新 → 跳过(保留服务端)
c. 若 id 存在且客户端 updatedAtUtc >= 服务端 updatedAtUtc → 字段级合并更新
5. 返回当前用户全量任务(含 Tombstone)
```
> 排序处理的目的:确保最新的变更优先被采纳。
#### 6.2.7 关于"服务端为准"的正确理解
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器
2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据
3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地
由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。
真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。
### 6.3 SyncRequest / SyncResponse 结构
**请求**`POST /sync`):
```json
{
"upserts": [
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null, "updatedAtUtc": "2026-04-06T17:00:00Z" },
{ "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null, "updatedAtUtc": "2026-04-06T17:30:00Z" }
],
"deletes": [2, 3]
}
```
- `id` 为服务端已知 ID`id > 0`)时执行更新;`id``null` / 0 / 负数时创建新记录
- `updatedAtUtc` 必填,客户端在发起变更时写入本地时间(UTC)
- `deletes` 为待逻辑删除的任务 ID 数组
**响应**
```json
{
"serverTimeUtc": "2026-04-06T17:39:30.0281279Z",
"tasks": [
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null, "createdAtUtc": "2026-04-01T00:00:00Z", "updatedAtUtc": "2026-04-06T17:00:00Z", "deletedAtUtc": null }
]
}
```
- `tasks` 返回当前用户**全量数据**(含 `deletedAtUtc != null` 的 Tombstone
- 客户端用响应全量覆盖本地数据,本地过滤不展示已删除任务
### 6.4 同步按钮调用规则
**触发入口**:主界面"同步"按钮(`TaskList.vue`),绑定 `syncNow()` 方法。
#### 6.4.1 前置检查
1. 检查 `isSyncing` 状态,防止并发重复调用
2. 检查登录状态(未登录则提示先登录)
#### 6.4.2 变更检测(增量同步核心)
本地维护 `pendingUpserts``pendingDeletes` 两个集合:
| 操作 | 记录时机 | 记录内容 |
|---|---|---|
| 新增任务 | 创建时 | `{ id: 临时负数, title, priority, ..., updatedAtUtc: 当前时间 }` |
| 修改任务 | 任何字段变更时 | `{ id, 全部字段, updatedAtUtc: 当前时间 }` |
| 删除任务 | 点击删除时 | `{ id }`(从 pendingUpserts 移除,加入 pendingDeletes|
> 每次用户操作后,更新本地 `updatedAtUtc`。使用 `seenIds` 集合去重,同一 `id` 只保留最新一条记录。
#### 6.4.3 数据准备
1. **合并 pendingUpserts**
- 调用 `flattenTasksToCloudUpserts(localTasks, pendingUpserts)` 生成 upsert 列表
- **ID 映射规则**
- `id > 0`:保留原 ID(服务端已知,执行更新)
- `id <= 0` 或未设置:置为 `null`(服务端分配新 ID,执行创建)
-`updatedAtUtc` 升序排列(较早的先发送,便于服务端 LWW 判断)
2. **生成 deletes 列表**
-`pendingDeletes` 提取待删除 ID
- 去重:`deletes = [...new Set(pendingDeletes.map(d => d.id))]`
#### 6.4.4 请求发送
- 端点:`POST /sync/`
- 请求体:`{ upserts: CloudTaskUpsert[], deletes: number[] }`
- 携带 `Authorization: Bearer {accessToken}`
#### 6.4.5 响应处理
1. **解析全量响应**
- 调用 `buildTaskTreeFromCloudItems(response.tasks)` 将扁平响应转为前端树结构
- 过滤 `deletedAtUtc == null` 的任务用于展示
2. **更新本地状态**
- `tasks.value = cloudTasks`(覆盖式更新,确保与服务端一致)
- `LocalStorageService.saveTasks(cloudTasks)`
- 清空 `pendingUpserts``pendingDeletes`
3. **更新同步状态**
- `lastSyncTime`:使用响应中的 `serverTimeUtc`
- `syncError = null`
#### 6.4.6 错误处理
| 错误类型 | 处理策略 |
|---|---|
| 401 未授权 | 清除本地会话,弹出重新登录提示 |
| 403 禁止 | Toast 提示权限不足 |
| 网络错误 | 重试(指数退避,最多 3 次),仍失败则保留 pending 数据下次同步 |
#### 6.4.7 完整流程图
```
┌─────────────────────────────────────────────────────────────┐
│ syncNow() 触发 │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 1. 前置检查:isSyncing? 已登录? │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. 变更检测:pendingUpserts + pendingDeletes │
│ - 遍历 tasks,收集 dirty 项(updatedAtUtc > lastSyncTime)│
│ - 新增/删除操作直接追加到 pending │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. 数据准备 │
│ - flattenTasksToCloudUpserts() │
│ - seenIds 去重 │
│ - 按 updatedAtUtc 排序 │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. POST /sync { upserts, deletes } │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. 响应处理 │
│ - buildTaskTreeFromCloudItems() │
│ - tasks.value = cloudTasks │
│ - saveTasks() │
│ - 清空 pendingUpserts / pendingDeletes │
│ - lastSyncTime = serverTimeUtc │
└─────────────────────────────────────────────────────────────┘
```
---
## 七、API 契约总览
### 7.1 通用约定
- Base URL:客户端同源请求,无需外部配置
- 认证:`Authorization: Bearer {accessToken}`
- 统一错误响应:
```json
{ "code": "ERROR_CODE", "message": "Human readable message." }
```
### 7.2 错误码
| 错误码 | HTTP 状态 | 含义 |
|---|---|---|
| `UNAUTHORIZED` | 401 | 未登录或会话失效 |
| `FORBIDDEN` | 403 | 权限不足(RBAC 拒绝 / 策略拒绝) |
| `BAD_REQUEST` | 400 | 请求参数不合法 |
| `NOT_FOUND` | 404 | 资源不存在 |
### 7.3 完整端点清单
| 路由 | 方法 | 权限 | 说明 |
|---|---|---|---|
| `/auth/bootstrap` | POST | 无(仅首次) | 初始化管理员账号 |
| `/auth/login` | POST | 匿名 | 登录,返回 AccessToken |
| `/auth/logout` | POST | 登录即可 | 登出,删除会话 |
| `/auth/change-password` | POST | 登录即可 | 修改当前用户密码 |
| `/tasks` | GET | `tasks:read` | 获取当前用户全量任务 |
| `/sync` | POST | `sync:write` | 上传 upsert + deletes,返回最新全量 |
| `/security/policy` | GET | `policy:read` | 获取当前用户安全策略 |
| `/security/policy` | PUT | `policy:write` | 修改当前用户安全策略 |
| `/cloud-sync/probe` | POST | 匿名 | 服务端代理探测目标 URL 可达性 |
| `/admin/users` | GET/POST/DELETE | `users:manage` | 用户管理 |
| `/admin/sessions` | GET/DELETE | `users:manage` | 会话管理 |
| `/admin/audit-logs` | GET | `users:manage` | 审计日志查询 |
---
## 八、前端实现规则
### 8.1 cloudClient.tsAxios 实例)
- 与通用 `client.ts`baseURL 含 `/api`)解耦,云同步专用。
- **请求拦截器**:MAUI 模式下从 `CloudSyncStorage` 读取 `serverUrl`Host 模式不设 baseURL 走同源;自动附加 `Bearer {accessToken}`
- **响应拦截器**
- 401 → 清除本地会话,弹出重新登录提示。
- 403 `FORBIDDEN` → Toast 提示权限不足。
- 其他错误 → 统一 Toast 通知。
### 8.2 cloudSync.tsAPI 封装)
提供 `cloudSyncApi` 对象,6 个方法:
| 方法 | 端点 | 核心逻辑 |
|---|---|---|
| `login()` | `/auth/login` | 登录后保存 Session 到 `CloudSyncStorage` |
| `logout()` | `/auth/logout` | 清除本地会话与缓存 |
| `getTasks()` | `/tasks` | 拉取全量 `CloudTaskItem[]`,通过 `buildTaskTreeFromCloudItems` 将扁平数据转为前端 Task 树 |
| `syncTasks()` | `/sync` | 通过 `flattenTasksToCloudUpserts` 将 Task 树扁平化为 upsert 列表,携带 deletes 提交 |
| `getSecurityPolicy()` | `/security/policy` | 获取策略,驱动落盘/内存模式切换 |
| `probeServerUrl()` | `/cloud-sync/probe` | 由服务端代理探测目标 URL |
### 8.3 CloudSyncSettingsDialog.vueUI 组件)
功能区块:
- **同步开关**:checkbox 控制,校验地址已保存方可开启。
- **服务端地址**:输入 + 规范化 + 保存并探测(`probeServerUrl`)。
- **登录区**:用户名/密码 → `login()` → 刷新策略与状态。
- **已登录区**:会话摘要、连接状态、安全策略展示、登出 + 立即同步。
- 通过 CustomEvent`cloudSyncStateChanged` / `cloudSyncTasksRequested`)与其他组件通信。
---
## 九、服务端代理探测规则
由研发工单 08 引入,`POST /cloud-sync/probe` 由 Host 代理探测目标 URL:
- 输入:`{ "targetUrl": "https://example.com" }`
- 输出:`{ "isReachable", "httpStatus", "isHttps", "title", "description", "type" }`type: `success` / `warn` / `error`
- 安全约束:
- 限制目标 URL 仅允许 HTTP/HTTPS 公网地址,禁止探测 localhost / 内网 IP(防 SSRF)。
- 建议加频率限制(如每分钟 3 次)。
---
## 十、审计日志
所有关键安全事件写入 `AuditLogs` 表:
- 登录成功 / 失败(含 IP、终端信息)
- 安全策略变更
- 用户管理操作(创建 / 删除 / 重置密码)
通过 `/admin/audit-logs` 端点查询,支持按时间、用户、事件类型过滤。
---
## 十一、约束与风险
| 约束 / 风险 | 缓解措施 |
|---|---|
| Token 吊销(会话非纯无状态) | `UserSessions` 表管理;登出即删记录,Token 立即失效 |
| HTTPS 强制 | 所有安全通信必须基于 TLS,防中间人攻击窃取 Token |
| 暴力破解 | `POST /auth/login` 建议加 Rate Limiting |
| MAUI 端不暴露云同步端点 | DI 注册边界严格执行:`AddCloudSyncServer()` 仅 Host 调用 |
| `allowPersist = false` 时数据丢失风险 | UI 强提示;退出时若未同步则二次确认 |
| 密码存储 | 使用 Argon2id 哈希(含 Salt),不存明文 |
| SSRF(探测端点) | `CloudProbeService` 限制目标为公网地址,禁止内网探测 |
---
> **相关文档**
> - PRD v1.2.0[产品需求文档-1.2.0.md](../project/产品需求文档-1.2.0.md)
> - 研发工单总览:[00-工单总览.md](../project/研发工单-v1.2.0/00-工单总览.md)
> - 服务端基础能力:[04-CloudSync-服务端基础能力.md](../project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md)
> - 客户端工作流:[05-CloudSync-客户端配置与工作流.md](../project/研发工单-v1.2.0/05-CloudSync-客户端配置与工作流.md)
> - 安全与可控落盘:[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)
> - 技术架构设计:[07-技术架构设计](./07-技术架构设计.md)
@@ -1,4 +1,6 @@
# Hua.Todo 代码规范文档 v1.1.0
# 代码规范
> 本文档面向开发者,定义 Hua.Todo 项目的编码规范。
## 1. 概述
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
@@ -10,21 +12,31 @@
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
- **一致性**: 在整个项目中保持命名风格一致
### 2.2 注释规范
- **公共 API 必须添加 XML 文档注释**
### 2.2 注释规范 (强制)
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
- **summary**:一句话说明用途,不允许重复嵌套 `<summary>` 标签
- **param / returns**:构造函数和方法的每个参数都必须有对应 `param`,包括可选参数、`logger` 等基础设施参数;有返回值时补充 `returns`
- **异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
- **XML 注释位置**`///` 文档注释必须紧贴被说明的语言元素;若元素还有特性(如 `[AttributeUsage]`),顺序必须是 XML 注释、特性、类型/成员声明
- **cref 使用**`<see cref="..."/>` 只引用当前项目能解析的类型/成员;跨程序集或未引入命名空间时改用普通文本,避免 CS1574 警告
- **复杂逻辑添加行内注释**
- **避免注释显而易见的代码**
- **保持注释与代码同步更新**
- **禁止在日志或注释中输出密钥、Token、用户隐私信息**
### 2.3 代码格式化
- **使用统一的代码格式化工具**
- **使用统一的代码格式化工具** (VS / IDE 默认格式化)
- **保持一致的缩进和空格**
- **每行代码不超过 120 字符**
- **文件末尾保留一个空行**
## 3. C# 代码规范
### 3.1 命名规范
### 3.1 跨平台逻辑规范
- **禁止混写 `#if`**:禁止在同一文件内混写多个平台的大段 `#if` 实现。
- **优先使用 partial/接口**:应优先使用 `partial` 类、接口与平台目录分离。
- **说明平台差异**:平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。
### 3.2 异步/后台任务
- **必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入**
#### 类和接口
```csharp
@@ -70,7 +82,7 @@ public void CreateTask(string title, TaskPriority priority)
public const int MaxTaskTitleLength = 200;
```
### 3.2 代码组织
### 3.3 代码组织
#### 文件结构
```csharp
@@ -82,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. 私有字段
@@ -116,7 +129,7 @@ public class TaskService : ITaskService
- 命名空间结构应与目录结构一致
- 使用 `.` 分隔层级
### 3.3 编码规范
### 3.4 编码规范
#### 异步编程
```csharp
@@ -183,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 工具注册部分。
-42
View File
@@ -1,42 +0,0 @@
# 技术栈与模块说明
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript
- **框架**Vue.js 3
- **构建工具**Vite
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
## 🎯 核心模块说明
### Hua.Todo.Core
领域实体层,定义核心实体(TaskEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
### Hua.Todo.Application
应用层实现,包含业务逻辑、动态 API 生成逻辑、EF Core 数据库上下文以及具体的服务实现(TaskService)。
### Hua.Todo.Host
后端 API 宿主,提供运行环境和配置,是后端服务的启动入口。
### 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。
-365
View File
@@ -1,365 +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)
- **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/ # 用户/开发者手册
│ │ ├── 技术栈与模块.md
│ │ ├── 版本记录.md
│ │ ├── 技术设计文档.md(本文件)
│ │ └── 代码规范文档.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.Api/ # 后端 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
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Middleware/ # 中间件
│ │ │ └── ExceptionMiddleware.cs
│ │ ├── Extensions/ # 扩展方法
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ │
│ ├── 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/ # 静态资源
│ │ │ └── index.html
│ │ ├── 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 后端 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.3 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
- 实现领域驱动设计模式
**关键组件**:
- `Entities`: 领域实体
- `Interfaces`: 业务接口定义
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.4 前端 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
@@ -1,51 +0,0 @@
# 版本更新历史
## 🔄 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### v1.2.0(开发中,2026-04-07
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
- **MAUIWindows)内嵌 API 文档**Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。
- **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 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
### 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 中“云同步”需求。
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持 (Windows, macOS, Android, iOS)
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
- 实现子任务支持
### v1.2.0 规划内容 (即将推出)
- **Linux 官方支持**:正式适配 Linux 平台。
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpakmanifest/desktop entry/AppStream)基础结构。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
@@ -1,92 +0,0 @@
# Android 端显示 “Not Found” 排查计划(Hua.Todo.Maui
## 目标
- 找出 Android 模拟器里只显示 `Not Found` 的根因(是 Web 资源缺失、内嵌 Web Server 路由/解析问题,还是 WebView 加载了错误地址)
- 给出可验证的修复方案,并确保修复后能在 Android 上正常加载前端页面
## 背景(当前实现快速定位)
- Android 使用自建 TCP HTTP Server,静态资源从 APK 的 `Assets/wwwroot/*` 读取:[MobileEmbeddedWebServerService](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs)
- WebView 默认加载内嵌服务器地址(`IsUsingStatic=true` 时):[MainPage.xaml.cs](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Views/MainPage.xaml.cs)
- Android 端静态文件找不到时返回纯文本 `Not Found`[HandleStaticAsync](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs#L214-L255)
## 排查顺序(从“最可能 & 最省时间”到“深入原因”)
### 1) 确认 WebView 实际加载的 URL
- 在 Android Debug 输出里确认 WebView Source(期望是 `http://localhost:5057``http://localhost:5057/`
- 如果不是内嵌地址,检查 `appsettings.json``WebServer.IsUsingStatic``ForEndUrl` 配置:[appsettings.json](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/appsettings.json)
判定:
- 若加载的是内嵌地址 → 继续第 2 步
- 若加载的是外部地址(ForEndUrl)→ 重点查 ForEndUrl 对应服务是否启动/路由是否正确
### 2) 确认前端 dist 是否存在且可用于打包
- 检查 `src/Hua.Todo.Web/dist/index.html` 是否存在
- 如果不存在:在 `src/Hua.Todo.Web` 下执行 `npm ci` + `npm run build`,确保产物生成
判定:
- dist 不存在/为空 → “Not Found”高概率来自 Android 静态资源根本没被构建或没被打进 APK
### 3) 确认 Android APK 内是否真的包含 `Assets/wwwroot/index.html`
- 重点验证打包结果是否存在:
- `assets/wwwroot/index.html`
- `assets/wwwroot/assets/*`(至少有 js/css
- 项目里通过 MSBuild 目标把 `Hua.Todo.Web/dist` 映射为 AndroidAssetLink 到 `wwwroot/...`):[Hua.Todo.Maui.csproj](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj#L150-L175)
判定:
- APK 内没有 `wwwroot/index.html` → 修复构建/打包流程(第 6 步会给方案)
- APK 内有 `wwwroot/index.html` → 继续第 4 步
### 4) 记录 Android 内嵌服务器的“收到的请求 Path”与“找不到的 assetPath”
目的:判断是否是“请求行解析不兼容”或“路径格式异常”导致找不到资源。
-`ReadRequestAsync``HandleStaticAsync` 临时输出:
- requestLine / target / path
- 计算出的 assetPath
- TryOpenAsset 失败的 assetPath
高频根因候选:
- WebView 请求行使用 absolute-form(例如 `GET http://localhost:5057/ HTTP/1.1`),当前解析逻辑会把整个 URL 当作 path,最终拼成无效 `wwwroothttp://...`,导致 404
### 5) 排除 WebView/网络限制类问题(只在必要时做)
- 如果看到的不是纯文本 `Not Found`,而是加载错误/空白:
- 检查 Android 明文 HTTP`http://localhost`)是否被允许
- 检查 `network_security_config.xml` 与 Manifest 配置:[network_security_config.xml](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/Resources/xml/network_security_config.xml)、[AndroidManifest.xml](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/AndroidManifest.xml)
### 6) 修复与验证(根据前面判定选择)
#### A. 资源缺失/未打包
- 让构建流程更“硬性”:
- 若 dist 不存在则强制构建,或在 Debug 也保证 `AndroidAsset` 包含 dist
- 可选:把 dist 复制进 `Hua.Todo.Maui/wwwroot` 再用 `<Content Include="wwwroot\**" />`/`<MauiAsset />` 统一打包(减少条件目标的不确定性)
验证:
- APK 内能看到 `assets/wwwroot/index.html`,启动后不再返回 `Not Found`
#### B. 请求路径解析不兼容(absolute-form 等)
- 改进 `ReadRequestAsync`:当 target 是 `http(s)://...` 时解析出其中的 Path + Query,再走现有逻辑
验证:
- 记录到的 path 变为 `/``/index.html`,能成功打开 `wwwroot/index.html`
#### C. 资源引用路径问题(js/css 请求 404)
- 检查 dist 中 `index.html``assets/*` 的引用路径是否与 AndroidAsset 的 Link 一致
- 若 Vite 输出含子目录(例如 `assets/chunks/...`),需要在 csproj 里用 `dist\**\*` 并保留 `%(RecursiveDir)`,避免扁平化导致引用断裂
验证:
- WebView 网络请求里 js/css 全部 200,页面正常渲染
## 本次排查的“最短闭环”
- 先确认 dist 是否存在 + APK 是否包含 `assets/wwwroot/index.html`
- 若存在仍 Not Found,再用日志确认 requestLine/target/path 是否被解析成异常值(absolute-form 是最高优先级怀疑点)
@@ -1,49 +1,53 @@
# Hua.Todo v1.2.0 任务拆分总览
# Hua.Todo v1.2.0 研发工单总览
本目录用于把 [产品需求文档-1.2.0.md](file:///d:/Proj/6.Hua.Todo/docs/project/产品需求文档-1.2.0.md) 拆解为可落地、可并行推进的子任务。各任务文件之间尽量解耦;存在明确依赖时会在任务内标注
> 术语澄清:本目录下"工单 / 子工单 / 研发工单"指**编码工作项**;文中提到的"任务/任务列表/任务标题/父子任务/拉取任务/同步任务"等指 Hua.Todo 项目业务领域的 **Todo 待办项**(Task 实体),二者请勿混淆。详见 `.trae/rules/全局/05-研发工单规则.md`
本目录用于把 [产品需求文档-1.2.0.md](file:///d:/Proj/6.Hua.Todo/docs/project/产品需求文档-1.2.0.md) 拆解为可落地、可并行推进的子工单。各工单文件之间尽量解耦;存在明确依赖时会在工单内标注。
## 目标(来自 PRD
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有本地 API ↔ 前端的交互协议
- 任务检索(Search):主界面顶部新增搜索框,按任务标题模糊匹配
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的可控落盘
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有"本地 API ↔ 前端"的交互协议
- Todo 待办项检索(Search):主界面顶部新增搜索框,按 Todo 待办项标题模糊匹配
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的"可控落盘"
## 当前实现基线(用于任务定位)
## 当前实现基线(用于工单定位)
- MAUI 启动与内嵌 WebServer`src/Hua.Todo.Maui`
- 前端(Vue)与 API client`src/Hua.Todo.Web`
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__``window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
## 任务流(并行建议)
## 工单流(并行建议)
- **Linux 入口线**`01-*` + `02-*`
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1``pack/linux/`
- **Search 线**`03-*`
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
- 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
- 03:已完成:主界面搜索框(按 Todo 待办项标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
- **云同步线**`04-*` + `05-*` + `06-*`
- 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离)
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与内存模式
- 04:服务端基础能力(登录、Todo 数据同步/读取、配置下发、用户隔离)
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取 Todo 数据
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与"内存模式"
- **文档与验收线**`07-*`
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
## 待验证表
| 子任务 | 实现状态 | 验证状态 | 备注 |
| 子工单 | 实现状态 | 验证状态 | 备注 |
|---|---|---|---|
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状 Todo 待办项标题包含匹配 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增云同步设置弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 |
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址校验+保存探测、登录/登出、登录后拉取云端 Todo 数据并在主界面只读展示 |
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
| 07 - 文档与验收 | 未标注 | 待验证 | |
| 08 - 同源 Host 重构 | 已设计 | 待实现 | Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` |
| 09 - 同步策略改进 | 待实现 | 待验证 | 重构 TaskEntity 继承 ABP 基类(`FullAuditedEntityWithUser<Guid, IdentityUser>`),主键从 `int` 改为 `Guid`,新增 ABP 审计字段(ExtraProperties/ConcurrencyStamp/CreationTime/CreatorId/LastModificationTime/LastModifierId/IsDeleted/DeletionTime/DeleterId |
## 交付判定(v1.2.0 Done Definition
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
- Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发是否允许落盘;客户端在禁止落盘时不产生本地持久化
- Search:可在主界面按 Todo 待办项标题实时过滤(含层级 Todo 的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户的 Todo 数据;服务端可下发"是否允许落盘";客户端在禁止落盘时不产生本地持久化
@@ -4,7 +4,7 @@
- 新增一个 **Linux 桌面端入口**Avalonia)作为 Hua.Todo 的 Linux 宿主
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
- 复用既有前端 ↔ 本地 API的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
- 复用既有"前端 ↔ 本地 API"的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
## 范围
@@ -16,7 +16,7 @@
## 依赖
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本工单,可并行
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`
## 关键决策点(必须在实现前落定)
@@ -30,7 +30,7 @@
选型必须满足的最低能力:
- 基础导航与本地资源加载
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证HTTP API路径通)
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证"HTTP API"路径通)
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
### 2) 资源加载策略(与 MAUI 对齐)
@@ -75,12 +75,12 @@
- 提供 App/Window/MainView 基础结构
2. 接入 WebView 控件并完成最小加载闭环
- 能显示 `index.html`,能打开前端路由
3. 对齐与 MAUI 端一致的前端契约
3. 对齐与 MAUI 端一致的"前端契约"
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
4. 复用本地 API(内嵌 WebServer
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
- 明确 Linux 端是否也走内嵌 WebServer + WebView 指向 BaseUrl的方式(推荐一致化,减少前端差异)
- 明确 Linux 端是否也走"内嵌 WebServer + WebView 指向 BaseUrl"的方式(推荐一致化,减少前端差异)
5. 输入法/字体/DPI 验证与可配置化
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
@@ -88,6 +88,6 @@
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
- WebView 正常渲染前端主界面
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少 Todo 待办项列表接口可用)
- 页面路由/刷新不会出现 404SPA fallback 生效)
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
@@ -2,7 +2,7 @@
## 目标
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到用户机器无需手动补大量依赖即可运行
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到"用户机器无需手动补大量依赖即可运行"
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
## 范围
@@ -17,7 +17,7 @@
## 交付形式(PRD 约束)
- 优先提供一种自包含官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
- 优先提供一种"自包含"官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
- 同时保留 `.deb``.tar.gz` 作为补充分发形式(以脚本产出为准)
## 实施步骤(建议)
@@ -25,7 +25,7 @@
1. 确定 Linux 发行版基线与运行时依赖清单
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
2. 选择并落地一种自包含交付形式
- AppImage:偏拎包即用,对依赖捆绑要求高
- AppImage:偏"拎包即用",对依赖捆绑要求高
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
3. 补充 `.deb``.tar.gz`
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
@@ -35,7 +35,6 @@
## 验收标准
- 产物可在干净环境(尽量接近用户机)安装/运行
- 产物可在"干净环境"(尽量接近用户机)安装/运行
- 启动后 WebView 能渲染前端,且本地 API 可用
- 文档中给出的安装/运行步骤可复现,且与产物一致
@@ -0,0 +1,36 @@
# 解决方案:统一版本与打包分发
## 现状分析
- **版本不一致**`Hua.Todo.Maui` 的版本号为 `1.2.3`,而 `Hua.Todo.Avalonia``Hua.Todo.Host` 默认使用 `1.0.0`
- **打包脚本缺失**:目前仅 `Hua.Todo.Maui` 拥有 `setup.iss` (Inno Setup) 打包脚本,版本号硬编码为 `1.2.2`
- **配置冗余**:版本信息分散在各个 `.csproj``.iss` 文件中,维护困难。
## 目标
- 统一所有项目的版本号为 `1.2.3`
- 采用 `Directory.Build.props` 集中管理全局属性。
-`Hua.Todo.Avalonia` 提供与 `Hua.Todo.Maui` 一致的 Windows 安装程序打包能力。
## 实施方案
### 1. 统一 .NET 项目版本号
- 在项目根目录创建 `Directory.Build.props` 文件。
- 定义全局版本号 `<Version>1.2.3</Version>`
- 定义全局公司、版权、产品名称等元数据。
- 移除各 `.csproj` 中冲突的版本定义。
### 2. 统一 Inno Setup 打包脚本
- **更新 `src/Hua.Todo.Maui/setup.iss`**
- 将版本号更新为 `1.2.3`
- 确保安装路径与应用名称一致。
- **为 `src/Hua.Todo.Avalonia` 创建 `setup.iss`**
- 参考 Maui 的脚本,调整 `Source` 路径为 Avalonia 的发布路径:`bin\Release\net10.0\win-x64\publish\*`
- 调整可执行文件名称为 `Hua.Todo.Avalonia.exe`
- 调整 AppId 以避免与 Maui 版本冲突。
### 3. 发布流程标准化
- 以后发布时,只需修改根目录下的 `Directory.Build.props` 即可同步所有项目的版本。
- 执行 `dotnet publish -c Release -r win-x64` 后,手动或通过脚本运行 `ISCC setup.iss` 生成安装包。
## 预期效果
- 所有程序集(DLL/EXE)的版本号均显示为 `1.2.3`
- 提供 `Hua.Todo_Avalonia_Setup_v1.2.3.exe``Hua.Todo_Maui_Setup_v1.2.3.exe` 两个安装包。
@@ -1,15 +1,17 @@
# 03 - Search任务标题关键词检索
# 03 - SearchTodo 待办项标题关键词检索
> 术语:本工单所指"Todo 待办项 / 任务标题 / 父子任务"为业务实体(Task / SubTask);"工单"指本研发工单本身。
## 目标
- 在主界面顶部增加搜索框
- 支持按任务标题进行模糊匹配(实时过滤)
- 支持按 Todo 待办项标题进行模糊匹配(实时过滤)
## 范围
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
- 过滤逻辑:对树状任务”给出明确策略
- 性能:任务量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
- 过滤逻辑:对"树状 Todo(父子任务)"给出明确策略
- 性能:Todo 数据量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
## 依赖
@@ -17,15 +19,15 @@
## 过滤策略(需在实现时确定,并写入实现说明)
树状任务(父子任务)常见两种策略,任选其一并保持一致:
树状 Todo(父子任务)常见两种策略,任选其一并保持一致:
1. **命中即显示(含上下文)**
- 任意节点标题命中则显示该节点
- 若子任务命中,可同时显示其祖先链(便于理解层级)
2. **扁平化命中**
- 只显示命中的任务(可能丢失层级语义)
- 只显示命中的 Todo(可能丢失层级语义)
建议优先采用命中即显示(含上下文),用户可更快定位。
建议优先采用"命中即显示(含上下文)",用户可更快定位。
## 实施步骤(建议)
@@ -39,7 +41,6 @@
## 验收标准
- 输入关键字后,任务列表实时过滤,仅展示命中结果(符合上述策略)
- 输入关键字后,Todo 列表实时过滤,仅展示命中结果(符合上述策略)
- 清空后恢复原列表
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
@@ -1,19 +1,21 @@
# 04 - 云同步(基础可用):服务端基础能力
> 术语:本工单文件本身归属"研发工单";文中"任务/Todo 数据"指业务实体(Task / SubTask)。
## 目标(PRD 约束)
- 支持用户级任务数据同步/读取(用户隔离)
- 支持用户级 Todo 待办项数据同步/读取(用户隔离)
- 提供基于角色/权限的访问控制(RBAC)
- 对高风险操作支持二次认证(能力以服务端实现为准)
- 下发可控落盘配置(服务端配置驱动)
- 下发"可控落盘"配置(服务端配置驱动)
## 范围(建议最小闭环)
- 认证(登录/会话)
- 任务数据 API(按用户隔离)
- Todo 数据 API(按用户隔离)
- 配置下发 API(是否允许落盘、终端可信度/会话策略等)
- RBAC 最小落地(至少能区分允许同步/禁止同步”或“只读/读写
- 二次认证最小落地(至少覆盖启用/关闭同步、切换账号、调整落盘策略等高风险操作的接口)
- RBAC 最小落地(至少能区分"允许同步/禁止同步"或"只读/读写"
- 二次认证最小落地(至少覆盖"启用/关闭同步、切换账号、调整落盘策略"等高风险操作的接口)
## 依赖
@@ -53,7 +55,7 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
- Body`{ "userName": "admin", "password": "..." }`
- 200`{ accessToken, expiresAtUtc, userId, role, permissions[] }`
- 二次认证(step-upv1.2.0 最小实现为复用登录口令进行再认证):
- 二次认证(step-upv1.2.0 最小实现为"复用登录口令进行再认证"):
- `POST /auth/step-up`
- Header`Authorization: Bearer ...`
- Body`{ "password": "..." }`
@@ -61,7 +63,7 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
### Tasks
- 获取当前用户任务全量:
- 获取当前用户 Todo 待办项全量:
- `GET /tasks`
- Header`Authorization: Bearer ...`
- 权限:`tasks:read`
@@ -120,26 +122,28 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
## 关键实现点(建议)
详细实现方案请参考:[06.1-CloudSync-服务端安全设计方案.md](file:///d:/Proj/6.Hua.Todo/docs/project/研发工单-v1.2.0/06.1-CloudSync-服务端安全设计方案.md)
1. 用户隔离
- 服务端所有读写必须绑定当前登录用户上下文
2. 权限模型(RBAC
- 定义最小角色:例如 `user``admin`
- 定义最小权限:例如 `tasks:read``tasks:write``sync:enable`
3. 二次认证
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现二次口令
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现"二次口令"
- 能力不足时必须返回明确错误,使客户端可提示用户
4. 落盘策略下发
- 支持按用户或按会话/终端下发
- 默认策略建议为允许落盘,便于可用性;但需支持禁止落盘
- 默认策略建议为"允许落盘",便于可用性;但需支持"禁止落盘"
## 验收标准
- 使用不同用户登录后获取任务,数据严格隔离
- 使用不同用户登录后获取 Todo 数据,数据严格隔离
- 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别)
- 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为
## 当前实现说明(v1.2.0
- 数据隔离:`Tasks` 表新增 `UserId` 外键,云端 API 的所有读写按当前会话用户隔离;本地模式使用固定的 `local` 用户 ID(不影响既有 Dynamic API)。
- RBAC:内置 `admin / user / readonly / nosync` 角色与权限映射;并叠加 `SecurityPolicies.AllowSync` 作为是否允许同步写入的策略开关。
- RBAC:内置 `admin / user / readonly / nosync` 角色与权限映射;并叠加 `SecurityPolicies.AllowSync` 作为"是否允许同步写入"的策略开关。
- 二次认证:通过 `POST /auth/step-up` 将会话提升到 step-up 状态;`POST /sync``PUT /security/policy` 会强制要求 step-up。
@@ -1,21 +1,23 @@
# 05 - 云同步(基础可用):客户端配置与同步工作流
> 术语:本工单文件归属"研发工单";文中"任务/Todo 数据"指业务实体(Task / SubTask)。
## 目标(PRD 约束)
- 首次启用同步时,用户需要**手动填写服务端地址**(例如 `https://example.com`),并允许后续在设置中修改
- 客户端对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息
- 用户登录成功后,从服务端获取该用户任务并更新到前端展示
- 用户登录成功后,从服务端获取该用户 Todo 数据并更新到前端展示
## 范围(建议最小闭环)
- 同步设置入口与界面(服务端地址、登录、同步开关)
- "同步设置"入口与界面(服务端地址、登录、同步开关)
- 地址校验与风险提示
- 登录后拉取任务并刷新 UI
- 与本地数据的关系(v1.2.0 建议先做到服务端为准/或本地为准的单一策略,避免引入复杂冲突解决)
- 登录后拉取 Todo 数据并刷新 UI
- 与本地数据的关系(v1.2.0 建议先做到"服务端为准/或本地为准"的单一策略,避免引入复杂冲突解决)
## 依赖
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取任务 + 获取安全策略)
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取 Todo 数据 + 获取安全策略)
- 与 `03-*`Search)互不影响,可并行
## 关键交互与状态
@@ -28,7 +30,7 @@
## 实施步骤(建议)
1. 增加同步设置UI
1. 增加"同步设置"UI
- 放置在主界面可发现位置(例如顶部右侧设置按钮/侧边栏)
2. 服务端地址配置
- 基础校验:`https?://`、host 合法性、尾部 `/` 处理规则
@@ -36,13 +38,12 @@
3. 登录与凭据存储策略(与 `06-*` 协同)
- 在允许落盘场景下可持久化 token/会话
- 在禁止落盘场景下仅保留内存会话(退出即失效)
4. 登录后拉取任务
- 拉取成功后更新前端任务列表
4. 登录后拉取 Todo 数据
- 拉取成功后更新前端 Todo 列表
- 拉取失败给出可理解提示,并允许重试
## 验收标准
- 首次启用同步必须先配置服务端地址;地址非法时不可保存并提示原因
- 保存地址时能提示不可达/证书异常等风险信息(至少能区分:成功、失败、存在风险但可继续)
- 登录后能从服务端拉取任务并展示在主界面
- 保存地址时能提示"不可达/证书异常"等风险信息(至少能区分:成功、失败、存在风险但可继续)
- 登录后能从服务端拉取 Todo 数据并展示在主界面
@@ -1,10 +1,12 @@
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与可控落盘
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与"可控落盘"
> 术语:本工单文件归属"研发工单";文中"任务/Todo 数据"指业务实体(Task / SubTask)。
## 目标(PRD 约束)
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整是否允许落盘等)
- 客户端读取服务端安全配置,决定任务信息是否允许落盘
- 当服务端标记终端不可信/不允许落盘时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整"是否允许落盘"等)
- 客户端读取服务端安全配置,决定 Todo 数据是否允许落盘
- 当服务端标记"终端不可信/不允许落盘"时,客户端只能在内存中持有 Todo 数据;退出应用后不保留 Todo 数据
## 范围
@@ -13,15 +15,16 @@
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
### 1) 落盘定义与边界
### 1) "落盘"定义与边界
需要明确哪些数据算落盘,并在禁止落盘时全部避免:
- 任务数据(列表/详情)
需要明确"哪些数据算落盘",并在禁止落盘时全部避免:
- Todo 数据(列表/详情)
- 同步状态(上次同步时间、待同步队列等)
- 登录凭据(token/refresh token/会话标识)
@@ -37,20 +40,19 @@
### 3) 二次认证(挑战-响应)
建议采用服务端驱动的挑战流程:
建议采用"服务端驱动"的挑战流程:
- 客户端发起高风险操作
- 服务端返回需要二次认证的响应(包含 challenge 信息)
- 服务端返回"需要二次认证"的响应(包含 challenge 信息)
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
- 客户端携带证明再次提交
若服务端暂不支持二次认证,也需有能力探测/降级提示,避免客户端卡死。
若服务端暂不支持二次认证,也需有"能力探测/降级提示",避免客户端卡死。
## 验收标准
- 服务端返回 `allowPersist=false` 时:
- 客户端不会把任务数据与凭据写入任何持久化介质
- 退出应用后重新进入,任务数据为空(需重新登录/拉取)
- 客户端不会把 Todo 数据与凭据写入任何持久化介质
- 退出应用后重新进入,Todo 数据为空(需重新登录/拉取)
- 对指定高风险操作:
- 无二次认证证明时被拒绝,并提示需要二次认证
- 提供正确二次认证后操作成功
@@ -0,0 +1,180 @@
# 06.1 - 云同步(安全进阶):服务端账户/凭据与策略实现方案
## 1. 目标
`04-*`(基础能力)与 `06-*`(落盘策略)提供服务端底层安全实现的具体方案,确保账户密码存储、Token 管理、二次认证及安全策略下发具备生产级安全性。
## 2. 账户与密码安全存储
服务端不应存储明文密码,需采用强哈希算法进行加盐处理。
### 存储结构 (Users 表)
- `UserId`: `Guid` (主键)
- `UserName`: `string` (唯一索引)
- `PasswordHash`: `string` (存储哈希后的结果)
- `PasswordSalt`: `string` (若哈希算法自带 Salt,如 BCrypt/Argon2,则可省略)
- `Role`: `string` (用户角色,如 admin/user)
- `CreatedAtUtc`: `DateTime`
- `UpdatedAtUtc`: `DateTime`
### 哈希算法建议
- **算法**: `Argon2id` (推荐) 或 `BCrypt`
- **配置**: 迭代次数、内存占用、并行度应符合 OWASP 最新建议。
***
## 3. Token 管理 (JWT)
采用 JSON Web Token (JWT) 作为会话凭据,并支持细粒度权限控制。
### Token 结构 (Payload)
```json
{
"sub": "UserId",
"name": "UserName",
"role": "Role",
"perms": ["tasks:read", "sync:write", "policy:read"],
"iat": 1712000000,
"exp": 1712003600,
"iss": "Hua.Todo.Server",
"aud": "Hua.Todo.Client",
"isStepUp": false // 是否通过了二次认证
}
```
### 校验逻辑
- 每次请求需携带 `Authorization: Bearer {token}`
- 服务端验证签名、有效期(exp)、签发者(iss)及受众(aud)。
- 权限校验:中间件检查 `perms` 声明是否包含当前接口所需的权限。
***
## 4. 二次认证与会话提升 (Step-up)
针对高风险操作(如 `sync:write``policy:write`),要求会话必须处于"提升状态"。
### 流程设计
1. **触发**: 客户端调用 `/sync`,服务端检查 Token 的 `isStepUp` 声明。
2. **拒绝**: 若 `isStepUp == false`,返回 `403 SECOND_FACTOR_REQUIRED`
3. **挑战**: 客户端调用 `/auth/step-up`,提供二次认证凭据(如再次输入密码,或 TOTP 验证码)。
4. **提升**: 验证成功后,服务端颁发一个新的 Token,其中 `isStepUp: true`,且有效期较短(如 30 分钟)。
5. **重试**: 客户端携带新 Token 重新发起请求。
***
## 5. 安全策略 (Security Policy) 存储与下发
安全策略决定了客户端的"落盘"行为及二次认证频率。
### 策略定义 (SecurityPolicies 表)
- `Id`: `int` (主键)
- `UserId`: `Guid` (外键)
- `AllowPersist`: `bool` (是否允许客户端持久化 Todo 数据)
- `AllowSync`: `bool` (是否允许该用户同步)
- `SecondFactorExpiryMinutes`: `int` (二次认证状态保持时长,默认 30)
- `IsTrustedDeviceOnly`: `bool` (是否仅限受信任终端)
### 下发逻辑
- 客户端通过 `GET /security/policy` 获取。
- 策略应与 `UserId` 绑定,允许管理员针对不同用户/角色进行差异化配置。
***
## 6. 审计日志 (Audit Logs)
记录关键安全事件,便于追溯。
- 登录成功/失败(记录 IP、终端信息)。
- 高风险操作尝试(是否通过二次认证)。
- 安全策略变更。
***
## 8. 客户端 UI 与交互设计
### 8.1 二次认证 (Step-up) 交互流程
1. **静默拦截**: 当用户触发高风险操作(如点击"立即同步"且 Token 已过期或未提升)时,客户端拦截请求并检测到 `403 SECOND_FACTOR_REQUIRED`
2. **弹出对话框**: 弹出"安全验证"模态框。
- **标题**: 需要二次认证。
- **描述**: "为了保护您的数据安全,执行此操作需要验证身份。"
- **输入**: 密码输入框(或 TOTP 验证码输入框)。
- **操作**: \[取消] \[验证并继续]。
3. **状态保持**: 验证成功后,UI 应显示短暂的"验证成功"提示,并自动重试刚才被拦截的操作。
### 8.2 客户端 UI (针对普通用户)
在客户端"设置 -> 云同步 -> 安全"路径下:
- **落盘策略 (AllowPersist)**:
- **展示**: 状态开关(Toggle)。
- **交互**:
- 若由服务端强制禁止,开关应为禁用状态(Disabled),并附带说明:"受服务端策略限制,当前终端禁止落盘"。
- 若允许修改,关闭开关时应弹出强提醒:"关闭后,所有本地 Todo 数据将被立即清除,退出应用后数据将不再保留。确定继续吗?"
- **二次认证频率**:
- **展示**: 显示当前二次认证的有效期(如 30 分钟)。
### 8.3 "不可落盘"模式下的视觉提示
`allowPersist == false` 时:
- **状态栏/标题栏**: 增加"无痕模式"或"内存存储"小图标/文字提醒。
- **登录页**: 增加提醒:"当前环境配置为禁止落盘,数据仅在本次运行期间有效"。
- **退出应用**: 点击退出时,若有未同步数据,强提醒:"数据未同步且本地禁止落盘,退出将导致数据丢失。确定退出吗?"
***
## 9. 服务端管理后台 (Admin Dashboard)
为了避免直接操作数据库,服务端需提供一套 Web 管理后台,供管理员维护账户、权限与安全策略。
### 9.1 工程位置与实现
- **宿主项目**: [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host)
- **实现方式**:
- 前端采用 **Vue 3 + Vite** 开发。
- 静态资源在构建后嵌入到 `Hua.Todo.Host``wwwroot` 目录或作为内嵌资源。
- 后端通过 ASP.NET Core 的 `UseStaticFiles()` 托管,并确保 `/admin` 路由指向前端入口。
### 9.2 系统初始化 (Bootstrap)
- **触发条件**: 当检测到数据库中无任何用户时,访问 `/admin` 自动跳转至 `/admin/bootstrap`
- **功能**: 创建首个"超级管理员"账号。
- **UI**: 简单的表单(用户名、密码、确认密码)。
### 9.3 用户管理 (User Management)
- **用户列表**: 展示所有已注册用户(UserId, UserName, Role, CreatedAt)。
- **创建用户**: 管理员手动添加用户(用于内部系统或受控注册)。
- **重置密码**: 为忘记密码的用户生成临时密码或直接重设(需审计记录)。
- **禁用/删除**: 软删除或禁用账号。
### 9.4 安全策略配置 (Security Policy Management)
- **全局策略**: 设置系统默认的 `allowPersist``allowSync``SecondFactorExpiry`
- **单用户覆盖**: 在用户详情页中,管理员可以针对特定高风险用户/终端覆盖全局策略(例如:对特定外包人员账号强制 `allowPersist = false`)。
### 9.5 会话与凭据管理 (Session Management)
- **在线会话查看**: 展示当前所有有效的 Token/会话(记录 IP、最后活跃时间、是否已提升权限)。
- **强制下线 (Revoke)**: 管理员可一键吊销特定用户的所有 Token(将其加入黑名单)。
### 9.6 审计日志查看器 (Audit Log Viewer)
- **过滤查询**: 按时间、用户、事件类型(登录、同步、策略变更)过滤。
- **高亮显示**: 对"二次认证失败"、"异常 IP 登录"等敏感事件进行红色高亮。
***
## 10. 约束与风险
- **Token 吊销**: 默认 JWT 是无状态的。若需支持强制下线,需引入 Redis/DB 黑名单。
- **HTTPS**: 所有安全通信必须基于 TLS,防止中间人攻击窃取 Token。
- **暴力破解**: 需在 `POST /auth/login` 接口增加限流(Rate Limiting)机制。
@@ -1,5 +1,7 @@
# 07 - 文档同步与验收清单(v1.2.0)
> 术语:本工单文件本身归属"研发工单";文中"任务/Todo 待办项"指业务实体(Task / SubTask)。
## 目标
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
@@ -9,7 +11,7 @@
- READMEFeatures、运行方式、API 说明(如涉及)
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
- 本目录工单文件:保持与实现同步(必要时更新验收口径与依赖关系)
## 必做同步点(结合当前仓库现状)
@@ -21,19 +23,18 @@
## 验收清单(建议逐项勾选)
### Linux
- [ ] Linux 上可启动并打开主界面
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
- [x] Linux 上可启动并打开主界面
- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [x] 前端能成功请求本地 `/api/*` 并加载 Todo 待办项列表
- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md
### Search
- [ ] 主界面有搜索框
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [ ] 清空后恢复原列表
- [x] 主界面有搜索框
- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [x] 清空后恢复原列表
### 云同步(基础可用)
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
- [ ] 登录后能拉取该用户任务并展示
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [ ] 高风险操作触发二次认证(服务端支持时)
- [x] 可手动配置服务端地址,并有基础校验与风险提示
- [x] 登录后能拉取该用户的 Todo 数据并展示
- [x] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [x] 高风险操作触发二次认证(服务端支持时)
@@ -0,0 +1,332 @@
## 云同步设置 — 服务端化改造方案
### 1. 当前架构概述
#### 1.1 项目层次关系
```
src/
├── Hua.Todo.Application/ ← 共享层(服务端 & 客户端公用)
│ ├── CloudSync/ ← 云同步业务逻辑
│ │ ├── Auth/ ← 认证/授权
│ │ ├── Models/ ← DTO(纯数据合约,两端可用)
│ │ └── Services/ ← 业务服务
│ ├── Data/ ← EF Core DbContext
│ └── DynamicApi/ ← 动态 API 中间件
├── Hua.Todo.Host/ ← ASP.NET 服务端(云同步接口的提供方)
│ └── Program.cs
│ ├── AddApplicationServices() ← Todo CRUD 服务
│ └── AddCloudSyncServer() ← 云同步认证/授权/端点 **仅服务端**
└── Hua.Todo.Maui/ ← 桌面/移动客户端(嵌入 WebView)
└── MauiProgram.cs
└── AddApplicationServices() ← **仅** Todo CRUD 服务
(不注册 AddCloudSyncServer,不暴露云同步端点)
```
#### 1.2 两种部署模式
**模式 A:Host 独立服务端(开发/部署)**
```
┌───────────────────────────────┐
│ Vue 前端 (Vite :5174) │
│ cloudClient │
│ baseURL = serverUrl(外部) │ 直连外部
│ → /auth/login │ ────────→ 外部云同步服务
│ probeReachability() │ 直连外部
│ → fetch(serverUrl) │ ────────→
├───────────────────────────────┤
│ proxy /api │
├───────────────────────────────┤
│ ASP.NET Host (:5173) │
│ AddApplicationServices() │ Todo CRUD
│ AddCloudSyncServer() │ 云同步端点 ← **服务端**
│ ├─ /api/* (本地 Todo) │
│ └─ /auth/* (云同步) │
│ /tasks/* │
│ /cloud-sync/probe (新增) │
└───────────────────────────────┘
```
**模式 BMAUI 嵌入式 WebView(桌面端/移动端)**
```
┌───────────────────────────────┐
│ MAUI WebView │
│ ┌─────────────────────────┐ │
│ │ Vue 前端 (静态托管) │ │
│ │ cloudClient │ │
│ │ baseURL = 同源 │ │ 同源请求
│ │ → /api/* (本地 Todo) │──┼───────→ Embedded WebServer
│ │ → /auth/* (❌ 不可用) │ │ (不暴露云同步端点)
│ └─────────────────────────┘ │
├───────────────────────────────┤
│ Embedded WebServer (:5057) │
│ AddApplicationServices() │ 仅 Todo CRUD
│ AddCloudSyncServer() │ ❌ 未注册
│ UseDynamicApi() │ 仅本地 Todo API
│ MapCloudSyncEndpoints() │ ❌ 未映射
└───────────────────────────────┘
```
> **关键区别**`Hua.Todo.Application` 被两方引用,但云同步能力**仅在 Host 端通过 `AddCloudSyncServer()` 激活**。MAUI 端只使用 `AddApplicationServices()` 获取 Todo CRUD 能力,**不暴露也不应暴露云同步端点**。
#### 1.3 核心问题
1. 前端的"保存并探测"使用**浏览器端直接 `fetch()`** 探测外部 URL,受 CORS、证书、网络隔离限制。
2. 前端的"登录"通过 `cloudClient` 直连外部 `serverUrl`session token 存在前端 `sessionStorage`,与 Host 脱节。
3. 在 Host 模式下,Host 本身就是云同步服务,但前端并不知道,额外配置了外部 `serverUrl`
### 2. 改造目标
| 操作 | 当前 | 目标 |
|---|---|---|
| **保存并探测** | 浏览器 `fetch()` 直连 | 前端 → Host 接口 → Application CloudProbeService → 返回结果 |
| **登录** | 前端直连 `serverUrl` | 前端 → Host `/auth/login` |
| **后续云同步请求** | `cloudClient` 直连 `serverUrl` | 统一走 Host(同源请求) |
### 3. 变更详情
#### 3.1 新增:服务端探测接口
> **注意**`CloudProbeService` 和服务端点注册均在 `AddCloudSyncServer()` 路径下,MAUI 端不受影响。
**Application 层新增 `CloudProbeService`**
位置:`src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs`
职责:接收目标 URL,从服务端发起 HTTP 探测,返回探测结果。
**新增 DTO**(位置:`src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs`,或新建 `CloudSync/Models/ProbeDtos.cs`
DTO 是纯数据合约,放在 Application 共享层无副作用,MAUI 端即使不调用也不会产生依赖。
```csharp
/// <summary>
/// 服务端探测请求。
/// </summary>
public class ProbeRequest
{
public string TargetUrl { get; set; } = string.Empty;
}
/// <summary>
/// 服务端探测响应。
/// </summary>
public class ProbeResponse
{
public bool IsReachable { get; set; }
public int? HttpStatus { get; set; }
public bool IsHttps { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>
/// success | warn | error
/// </summary>
public string Type { get; set; } = "error";
}
```
**DI 注册(`CloudSyncServiceCollectionExtensions.AddCloudSyncServer()`**
`AddCloudSyncServer()` 方法末尾追加,确保 `CloudProbeService` 仅在服务端 DI 容器中可用:
```csharp
// CloudSyncServiceCollectionExtensions.AddCloudSyncServer() 末尾新增:
services.AddHttpClient("ProbeClient", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
});
services.AddScoped<CloudProbeService>();
```
> **为什么放在 `AddCloudSyncServer()` 而非 `AddApplicationServices()`**
> - `AddApplicationServices()` 被 MAUI 端调用,MAUI 不需要也不会启动云同步探测
> - `AddCloudSyncServer()` 仅在 Host 的 `Program.cs` 中被调用,确保服务仅注册在服务端
**端点注册(`CloudSyncEndpointExtensions.MapCloudSyncEndpoints()`**
`MapCloudSyncEndpoints()` 方法内部新增端点组:
```csharp
var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup");
probe.MapPost("/probe", ProbeAsync).AllowAnonymous();
```
端点实现:
```csharp
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);
}
```
**前端修改** `src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue`
删除 `probeReachability()` 函数(原 L235-L280),`saveServerUrl()` 中改为:
```typescript
// 旧:前端直接 fetch
// probeResult.value = await probeReachability(serverUrlSaved.value);
// 新:调用 Host 的探测接口
const response = await cloudClient.post<ProbeResponse>('/cloud-sync/probe', {
targetUrl: serverUrlSaved.value,
});
probeResult.value = {
type: response.data.type as ProbeType,
title: response.data.title,
desc: response.data.description,
};
```
#### 3.2 修改:登录统一走 Host
**核心思路**Host 自身就是云同步服务方,`/auth/login` 已实现完整认证。前端只需改为走 Host 同源请求。
**前端 `cloudClient.ts` 改造**`src/Hua.Todo.Web/src/api/cloudClient.ts`
移除对外部 `serverUrl` 的依赖:
```typescript
// 改造前(L36-L39):
// const { serverUrl } = CloudSyncStorage.loadSettings();
// if (serverUrl) { config.baseURL = serverUrl; }
// 改造后:不设 baseURL,axios 使用浏览器默认同源
// cloudClient 请求直接打到当前 Host/auth/* , /tasks/* , /cloud-sync/*
```
> **同源适配说明**
> - **Host 模式**Vite dev):Vue 运行在 :5174`/api` 走 proxy 到 Host(:5173),云同步端点 `/auth/*` 需配 Vite proxy 或直接用 Host 地址
> - **MAUI 模式**WebView):Vue 部署在 MAUI 嵌入服务器同源,不需要额外代理
> - 具体方案:在 Vite dev 时增加 proxy 规则将 `/auth`、`/tasks`、`/cloud-sync` 也 proxy 到 Host;或在 cloudClient 中按环境设置 baseURL
**前端 `CloudSyncSettingsDialog.vue` 的 `login()` 无需修改**
`cloudSyncApi.login()` 调用 `cloudClient.post('/auth/login', ...)`,自动打到 Host。
**Host 端 `CloudAuthService.LoginAsync` 已有完整实现**
- 创建 `UserSessionEntity` 写入 DBsession 管理在 Host
- 返回 `LoginResponse``AccessToken` = DB SessionId
- Session 验证由 `SessionAuthenticationHandler` 查询 DB 完成
#### 3.3 Vite 代理配置补充
Vite dev 模式下,除了已有的 `/api` 代理,需**补充云同步端点的代理规则**(`vite.config.ts`):
```typescript
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
// 新增:云同步端点代理到 Host
'/auth': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/tasks': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/sync': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/security': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
'/cloud-sync': {
target: 'http://localhost:5173',
changeOrigin: true,
secure: false,
},
},
},
```
> **MAUI 模式无需此配置**MAUI 使用 `vite build --mode maui`,静态部署到嵌入服务器同源。
#### 3.4 `serverUrl` 字段的语义变化
| 维度 | 改造前 | 改造后 |
|---|---|---|
| **用途** | 云同步 API 的 base URL(前端直连) | 仅用于"服务端探测"的目标地址 |
| **存储位置** | `localStorage` | 不变(探测时使用) |
| **影响登录/Todo 拉取** | 是(前端用它做 baseURL) | 否(走 Host 自身端点) |
### 4. 需要变更的文件清单
| 层 | 文件 | 变更类型 | 说明 |
|---|---|---|---|
| Application | `Services/CloudProbeService.cs` | **新增** | 服务端探测逻辑,注册于 `AddCloudSyncServer()` |
| Application | `Models/AdminDtos.cs`(或新 `Models/ProbeDtos.cs` | **新增 DTO** | `ProbeRequest` / `ProbeResponse`(纯数据合约) |
| Application | `CloudSyncEndpointExtensions.cs` | **修改** | 注册 `POST /cloud-sync/probe` |
| Application | `CloudSyncServiceCollectionExtensions.cs` | **修改** | `AddCloudSyncServer()` 内注册 `CloudProbeService` + `HttpClient` |
| Host | `Program.cs` | **无需修改** | `MapCloudSyncEndpoints()` 自动包含新端点 |
| Web(Vue) | `vite.config.ts` | **修改** | 补充云同步端点的 dev proxy |
| Web(Vue) | `api/cloudClient.ts` | **修改** | 移除外部 `serverUrl` baseURL |
| Web(Vue) | `api/cloudSync.ts` | **修改** | 新增 `probeServerUrl()` 方法 |
| Web(Vue) | `components/CloudSyncSettingsDialog.vue` | **修改** | 删除 `probeReachability()`,改用 API 调用 |
| 层 | 文件 | 变更 | 说明 |
|---|---|---|---|
| MAUI | `MauiProgram.cs` | **无需修改** | 不注册 `AddCloudSyncServer()` |
| MAUI | `EmbeddedWebServerService.cs` | **无需修改** | 不映射 `MapCloudSyncEndpoints()` |
### 5. 端点路由变更汇总
| 路由 | 方法 | 变更 | 可用环境 |
|---|---|---|---|
| `/cloud-sync/probe` | POST | **新增** | HostVite dev proxy/ MAUI(同源请求到 Host |
| `/auth/login` | POST | 无变更 | 同上 |
| `/auth/step-up` | POST | 无变更 | 同上 |
| `/tasks/` | GET | 无变更 | 同上 |
| `/security/policy` | GET | 无变更 | 同上 |
### 6. 服务端/客户端职责边界总结
```
┌──────────────────────────────────────────────────────────────┐
│ Hua.Todo.Application (共享层) │
│ │
│ AddApplicationServices() AddCloudSyncServer() │
│ ├─ TodoDbContext ├─ CloudAuthService │
│ ├─ TaskRepository ├─ CloudAdminService │
│ ├─ TaskService ├─ CloudTaskSyncService │
│ ├─ DynamicApi ├─ SecurityPolicyService │
│ └─ DTOs (Models/*) ├─ CloudProbeService (新) │
│ ↑ 纯数据合约,两端安全 ├─ Authentication/Policy │
│ └─ MapCloudSyncEndpoints │
│ │
│ Host 注册: AddApplicationServices + AddCloudSyncServer │
│ MAUI 注册: AddApplicationServices only │
└──────────────────────────────────────────────────────────────┘
```
### 7. 安全考量
| 风险点 | 缓解措施 |
|---|---|
| SSRF(探测端点攻击内网) | `CloudProbeService` 限制目标 URL 必须是 HTTP/HTTPS 公网地址,禁止探测 localhost/内网 IP |
| 探测请求被滥用 | 加频率限制(如每分钟 3 次),仅允许同源请求 |
| MAUI 端不暴露云同步端点 | `AddCloudSyncServer()` 仅在 Host 调用,MAUI 无法访问云同步端点 |
@@ -0,0 +1,734 @@
# CloudSync 同步策略改进方案
> 研发工单序号:09
> 依赖:04-CloudSync-服务端基础能力、05-CloudSync-客户端配置与工作流
> 状态:待实现
---
## 一、背景与问题
当前同步规则([02-任务同步规则.md](../../manual/02-任务同步规则.md) 6.2 节)存在以下问题:
1. **TaskEntity 缺少 ABP 审计字段**:未继承 ABP 基类,缺少 `ExtraProperties``ConcurrencyStamp``CreationTime``CreatorId``LastModificationTime``LastModifierId` 等标准字段
2. **主键类型不符合 ABP 规范**ABP 要求主键为 `Guid`,当前为 `int`
3. **软删除字段缺失**:没有 `IsDeleted``DeletionTime` 字段,无法实现 Tombstone 逻辑删除
---
## 二、ABP 审计字段规范(必须遵守)
根据 [表设计与字段命名规范.md](file:///d:/Codes/CodeSmith/CodeSmith/ShaoHua.CodeSmith.Abp/docs/2.表设计与字段命名规范.md)`FullAuditedEntityWithUser<Guid, IdentityUser>` 基类提供以下字段:
| ABP 标准字段 | 含义 | 类型 |
|---|---|---|
| `Id` | 主键 | `Guid`ABP 强制要求) |
| `ExtraProperties` | 扩展属性字典 | `ExtraPropertyDictionary` |
| `ConcurrencyStamp` | 并发戳,用于乐观并发控制 | `string?` |
| `CreationTime` | 创建时间 | `DateTime` |
| `CreatorId` | 创建人Id | `Guid?` |
| `LastModificationTime` | 最后修改时间 | `DateTime?` |
| `LastModifierId` | 最后修改人Id | `Guid?` |
| `IsDeleted` | 软删除标记 | `bool` |
| `DeletionTime` | 删除时间 | `DateTime?` |
| `DeleterId` | 删除人Id | `Guid?` |
### 字段忽略规则(ShouldIgnoreList
以下字段在实体中**不生成任何属性**:
| 字段名 | 理由 |
|---|---|
| `ExtraProperties` | ABP 框架扩展属性,基类已处理 |
### 不可编辑字段(NotEnableEditList
以下字段在更新 DTO 中**不可编辑**:`IsDeleted``CreationTime``CreatorId``LastModificationTime``LastModifierId``DeletionTime``DeleterId`
---
## 三、TaskEntity 重构方案
### 3.1 数据库表名(ABP 规范)
> ABP 框架约定表名格式为 `T_{实体名}s`,例如实体 `TaskEntity` 对应表名 `T_Tasks`。
> EF Core 迁移脚本中的 `table: "Tasks"` 为 DbContext 配置的表名,两者须保持一致。
### 3.2 继承关系变更
```csharp
// 原
public class TaskEntity
{
public int Id { get; set; }
// ...
}
// 改后
public class TaskEntity : FullAuditedEntityWithUser<Guid, IdentityUser>
{
// Id、ExtraProperties、ConcurrencyStamp、CreationTime、CreatorId、
// LastModificationTime、LastModifierId、IsDeleted、DeletionTime、DeleterId
// 均由基类提供
}
```
### 3.2 业务字段保留
| 业务字段 | 类型 | 说明 |
|---|---|---|
| `UserId` | `Guid` | 任务所属用户(云端隔离),本地为 `"local"` |
| `Title` | `string` | 任务标题 |
| `Priority` | `TaskPriority` | 优先级枚举 |
| `IsCompleted` | `bool` | 是否完成 |
| `ParentTaskId` | `Guid?` | 父任务ID(外键) |
> **ParentTaskId 类型变更**:从 `int?` 改为 `Guid?`,与主键类型一致。
### 3.3 导航属性
```csharp
public class TaskEntity : FullAuditedEntityWithUser<Guid, IdentityUser>
{
public UserEntity? User { get; set; }
public TaskEntity? ParentTask { get; set; }
public List<TaskEntity> SubTasks { get; set; } = new();
}
```
---
## 四、DTO 重构方案(与数据库保持一致)
### 4.1 CloudTaskItemC# → JSON 响应)
```csharp
// src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs
/// <summary>
/// 云同步任务条目(ABP 标准)。
/// </summary>
public class CloudTaskItem
{
/// <summary>
/// 任务 ID(服务端分配,Guid)。
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 IDGuid)。
/// </summary>
public Guid? ParentTaskId { get; set; }
// === ABP 审计字段 ===
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreationTime { get; set; }
/// <summary>
/// 创建人 ID。
/// </summary>
public Guid? CreatorId { get; set; }
/// <summary>
/// 最后修改时间(用于 LWW 冲突判断)。
/// </summary>
public DateTime? LastModificationTime { get; set; }
/// <summary>
/// 最后修改人 ID。
/// </summary>
public Guid? LastModifierId { get; set; }
/// <summary>
/// 软删除标记。
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// 删除时间。
/// </summary>
public DateTime? DeletionTime { get; set; }
/// <summary>
/// 删除人 ID。
/// </summary>
public Guid? DeleterId { get; set; }
}
```
### 4.2 CloudTaskUpsertC# → JSON 请求)
```csharp
/// <summary>
/// 任务 Upsert DTO(客户端提交)。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为 null 表示新建。
/// </summary>
public Guid? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public Guid? ParentTaskId { get; set; }
/// <summary>
/// 客户端发起变更时写入的时间戳(UTC),用于 LWW 冲突判断。
/// </summary>
public DateTime? LastModificationTime { get; set; }
}
```
### 4.3 SyncRequest / SyncResponse
```csharp
/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
/// <summary>
/// 新增或更新的任务列表。
/// </summary>
public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary>
/// 需要逻辑删除的任务 ID 列表。
/// </summary>
public List<Guid> Deletes { get; set; } = new();
}
/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
/// <summary>
/// 服务端时间(UTC)。
/// </summary>
public DateTime ServerTimeUtc { get; set; }
/// <summary>
/// 当前用户的任务全量(含 Tombstone)。
/// </summary>
public List<CloudTaskItem> Tasks { get; set; } = new();
}
```
### 4.4 JSON 序列化配置
DTO 默认使用 PascalCaseABP 标准),通过 `[JsonPropertyName]` 特性控制 JSON 输出:
```csharp
public class CloudTaskItem
{
[JsonPropertyName("id")]
public Guid Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
// ... 其他字段
}
```
或通过全局配置启用 camelCase:
```csharp
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
```
---
## 五、前端类型定义(与 DTO 保持一致)
### 5.1 当前状态 vs 目标状态对比
| 字段 | 当前类型 | 目标类型 | 变更说明 |
|---|---|---|---|
| `id` | `number` | `string` | 主键从 int 改为 GuidJSON 序列化为字符串 |
| `parentTaskId` | `number \| undefined` | `string \| null` | 外键类型同步变更 |
| `createdAt` | `string` | **移除** | 替换为 `creationTime` |
| `updatedAt` | `string` | **移除** | 替换为 `lastModificationTime` |
| `subTasks` | `Task[]` | `Task[]` | 类型不变,递归结构 |
| — | — | `creationTime: string` | 新增:ABP 创建时间 |
| — | — | `creatorId: string \| null` | 新增:创建人 ID |
| — | — | `lastModificationTime: string \| null` | 新增:最后修改时间(LWW 冲突判断) |
| — | — | `lastModifierId: string \| null` | 新增:最后修改人 ID |
| — | — | `isDeleted: boolean` | 新增:软删除标记 |
| — | — | `deletionTime: string \| null` | 新增:删除时间 |
| — | — | `deleterId: string \| null` | 新增:删除人 ID |
### 5.2 task.ts 目标定义
```typescript
// src/Hua.Todo.Web/src/types/task.ts
export type TaskPriority = 0 | 1 | 2;
/**
* Todo 待办项(与 CloudTaskItem DTO 一一对应)。
* - 主键 id 为 Guid 序列化后的字符串
* - 包含 ABP 审计字段,用于云同步冲突判断
*/
export interface Task {
// === 业务字段 ===
id: string; // Guid 序列化
title: string;
priority: TaskPriority;
isCompleted: boolean;
parentTaskId: string | null; // Guid 类型,null 表示顶级任务
// === ABP 审计字段 ===
/** 创建时间(服务端分配) */
creationTime: string;
/** 创建人 ID */
creatorId: string | null;
/** 最后修改时间(用于 LWW 冲突判断) */
lastModificationTime: string | null;
/** 最后修改人 ID */
lastModifierId: string | null;
/** 软删除标记(前端本地过滤不展示) */
isDeleted: boolean;
/** 删除时间(不为 null 时表示已逻辑删除) */
deletionTime: string | null;
/** 删除人 ID */
deleterId: string | null;
// === 导航属性 ===
subTasks: Task[];
}
/**
* 创建 Todo 待办项请求(客户端 → 服务端,新建时不带 id)
*/
export interface CreateTaskDto {
title: string;
priority: TaskPriority;
parentTaskId?: string; // Guid 字符串
}
/**
* 更新 Todo 待办项请求(客户端 → 服务端,必须带 id)
*/
export interface UpdateTaskDto {
id: string; // Guid 字符串
title?: string;
priority?: TaskPriority;
isCompleted?: boolean;
}
```
### 5.3 前端变更检测逻辑
```typescript
// src/Hua.Todo.Web/src/types/task.ts 或业务层
/**
* 标记任务为脏(本地修改待同步)。
* - 每次用户操作(创建/修改/删除)时调用
* - 更新 lastModificationTime 为当前 UTC 时间
*/
export function markTaskDirty(task: Task): void {
task.lastModificationTime = new Date().toISOString();
task.lastModifierId = getCurrentUserId(); // 若已登录
pendingUpserts.set(task.id, task);
}
/**
* 标记任务为待删除(本地删除待同步)。
* - 设置 deletionTime 而非真正从数组移除
*/
export function markTaskDeleted(task: Task): void {
task.isDeleted = true;
task.deletionTime = new Date().toISOString();
task.deleterId = getCurrentUserId();
pendingDeletes.add(task.id);
}
```
### 5.4 API 响应处理
```typescript
// src/Hua.Todo.Web/src/api/cloudSync.ts
import type { Task } from '@/types/task';
/**
* 获取云端全量任务(含 Tombstone)。
* 返回数据直接赋值给本地 store,无需字段转换。
*/
export async function fetchCloudTasks(): Promise<Task[]> {
const response = await cloudSyncApi.getTasks();
// response.data 类型已是 Task[],服务端返回的 Guid 已序列化为 string
return response.data;
}
/**
* 拉取全量后,本地过滤不展示已删除任务。
*/
export function filterVisibleTasks(tasks: Task[]): Task[] {
return tasks.filter(task => task.deletionTime === null);
}
```
### 5.5 pendingUpserts / pendingDeletes 类型修正
```typescript
// src/Hua.Todo.Web/src/stores/taskStore.ts(示例)
import type { Task } from '@/types/task';
// 待上传的脏任务(key: task.id,即 Guid string
const pendingUpserts = reactive(new Map<string, Task>());
// 待上传的删除任务 IDGuid string
const pendingDeletes = reactive(new Set<string>());
```
### 5.6 Guid 字符串处理工具
```typescript
// src/Hua.Todo.Web/src/utils/guid.ts
/**
* 生成新的 Guid 字符串(客户端创建临时任务时使用)。
* 格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx(小写)
*/
export function generateGuid(): string {
return crypto.randomUUID();
}
/**
* 判断字符串是否为有效 Guid 格式。
*/
export function isValidGuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}
```
---
## 六、迁移脚本
### 6.1 EF Core 迁移命令
```powershell
cd src/Hua.Todo.Application
dotnet ef migrations add MakeTaskEntityAbpCompatible --startup-project ../Hua.Todo.Host
```
### 6.2 迁移内容预期
此迁移涉及大量字段变更,建议分阶段执行或使用"双写/兼容期"策略:
```csharp
// MakeTaskEntityAbpCompatible.cs
// 表名:T_TasksABP 规范),DbContext 配置为 "Tasks"
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. 新增 Guid Id 列(临时名)
migrationBuilder.AddColumn<Guid>(
name: "NewId",
table: "T_Tasks",
type: "TEXT",
nullable: false,
defaultValue: Guid.NewGuid());
// 2. 将旧 int Id 数据迁移到 NewId
// (需要手动 SQL 脚本或数据迁移)
// 3. 删除旧 Id 列,重命名 NewId 为 Id
migrationBuilder.DropColumn("Id", "T_Tasks");
migrationBuilder.RenameColumn("NewId", "T_Tasks", "Id");
// 4. 新增 ABP 审计字段
migrationBuilder.AddColumn<string>(
name: "ExtraProperties",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ConcurrencyStamp",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreationTime",
table: "T_Tasks",
type: "TEXT",
nullable: false,
defaultValue: DateTime.UtcNow);
migrationBuilder.AddColumn<Guid?>(
name: "CreatorId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime?>(
name: "LastModificationTime",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid?>(
name: "LastModifierId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "T_Tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime?>(
name: "DeletionTime",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid?>(
name: "DeleterId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
// 5. ParentTaskId 从 int 改为 Guid
migrationBuilder.DropColumn("ParentTaskId", "T_Tasks");
migrationBuilder.AddColumn<Guid?>(
name: "ParentTaskId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// 回滚逻辑...
}
```
> **注意**:此迁移为破坏性变更,建议在离线环境充分测试后再部署到生产环境。
---
## 七、同步策略(与 02-任务同步规则.md 保持一致)
> 本章节同步策略逻辑完全遵循 [02-任务同步规则.md](../../manual/02-任务同步规则.md) 6.2 节,字段名使用 ABP 标准。
### 7.1 核心原则
- **任务唯一标识是 `id`**`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
- **`lastModificationTime` 是冲突判断字段**:用于解决"同一任务被多端修改时哪个变更更新"的问题
- **服务端存储唯一真实源(Source of Truth)**:所有设备最终都收敛到服务端数据
- **字段级 Last-Write-WinsLWW)合并**:同一字段的多设备并发修改,以 `lastModificationTime` 较新者为准
- **逻辑删除(Tombstone**:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致
- **增量同步**:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量
### 7.2 变更追踪机制
每个 `CloudTaskItem` 包含以下时间戳字段:
| ABP 字段 | 说明 |
|---|---|
| `creationTime` | 创建时间(服务端分配,永不改变) |
| `lastModificationTime` | 最后一次修改时间(每次字段变更时更新) |
| `isDeleted` | 软删除标记 |
| `deletionTime` | 逻辑删除时间(为 `null` 表示未删除) |
| `creatorId` | 创建人 ID |
| `lastModifierId` | 最后修改人 ID |
> `lastModificationTime` 由**请求发起方**(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。
### 7.3 冲突解决规则
当客户端提交的任务与服务端现有任务发生 `id` 冲突时,按以下规则处理:
| 场景 | 解决规则 |
|---|---|
| 同一 `id`,客户端 `lastModificationTime` **更新** | 服务端接受客户端版本,覆盖对应字段 |
| 同一 `id`,服务端 `lastModificationTime` **更新** | 服务端拒绝客户端版本,保留服务端版本 |
| 同一 `id`,两者 `lastModificationTime` **相同** | 服务端优先(保守策略) |
**字段级合并示例**
- 设备 A 修改标题,设备 B 修改优先级
- 服务端对两个字段分别取 `lastModificationTime` 较新者,合并出最终结果
- 两个设备的修改都被保留,不会互相覆盖
### 7.4 父子任务处理
- **删除**:删除父任务时,其子任务一并标记为已删除(递归)
- **创建**:子任务的 `parentTaskId` 在上传时若为 `null`(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射
- **重建父子关系**:若原父任务被删除后重建,子任务的 `parentTaskId` 引用仍指向原父 ID,不会自动指向新父
### 7.5 Tombstone 保留策略
- `deletionTime``null` 的任务在服务端保留至少 **30 天**
- 超过 30 天后由服务端垃圾收集(物理删除)
- 客户端同步时拉取全量(含 Tombstone),本地过滤不展示 `deletionTime != null` 的任务
### 7.6 服务端处理流程
`POST /sync` 服务端执行顺序:
```
1. 解析 upserts 和 deletes
2. 对 deletes 中的每个 id
- 递归标记该任务及所有子任务 deletionTime = now, isDeleted = true
3. 对 upserts 按 lastModificationTime 降序排列(较新的先处理)
4. 对每个 upsert 任务:
a. 若 id 在服务端不存在 → 创建新记录(分配 Guid)
b. 若 id 存在但服务端 lastModificationTime 更新 → 跳过(保留服务端)
c. 若 id 存在且客户端 lastModificationTime >= 服务端 lastModificationTime → 字段级合并更新
5. 返回当前用户全量任务(含 Tombstone)
```
> 排序处理的目的:确保最新的变更优先被采纳。
### 7.7 关于"服务端为准"的正确理解
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器
2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据
3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地
由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。
真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。
---
## 九、服务端代码改动
### 9.1 TaskRepository 查询过滤
```csharp
// 基类已自动过滤 IsDeleted = true 的记录
// 但若需要显式查询(含已删除),使用 _context.Tasks.IgnoreQueryFilters()
```
### 9.2 CloudTaskSyncService 逻辑删除处理
```csharp
// POST /sync 处理 deletes
foreach (var id in request.Deletes)
{
var task = await _context.Tasks.FindAsync(id);
if (task != null && !task.IsDeleted)
{
await _taskRepository.DeleteAsync(task); // 调用基类软删除
}
}
```
---
## 十、验收标准
1. [ ] TaskEntity 继承 `FullAuditedEntityWithUser<Guid, IdentityUser>`
2. [ ] 主键类型从 `int` 迁移到 `Guid`
3. [ ] EF Core 迁移成功执行,新增 ABP 审计字段
4. [ ] 服务端 `/tasks` 端点返回 ABP 标准字段
5. [ ] 服务端 `/sync` 端点正确处理软删除
6. [ ] 前端正确适配 Guid 类型和 ABP 字段
7. [ ] 同步规则文档(02-任务同步规则.md)字段命名已修正
8. [ ] 数据模型约束文档(03-数据模型与迁移约束.md)已更新
---
## 十一、风险与缓解
| 风险 | 缓解措施 |
|---|---|
| 主键从 int 变 Guid 为破坏性迁移 | 使用双写/兼容期策略;提供数据迁移脚本 |
| 迁移破坏嵌入式宿主启动 | 在 MAUI/Avalonia 上验证 `Database.Migrate()` 不报错 |
| 前端类型变更导致编译错误 | 同步更新 TypeScript 类型定义 |
| 旧客户端不兼容新 API | API 版本化;新字段为可选/默认值 |
**回滚方案**
- 迁移回滚:`dotnet ef migrations remove`(可能丢失数据)
- 代码回滚:恢复 TaskEntity 原定义
---
## 十二、Touch List
| 文件路径 | 改动类型 | 共享文件 |
|---|---|---|
| `src/Hua.Todo.Core/Entities/TaskEntity.cs` | 重构(继承 ABP 基类) | 否 |
| `src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs` | DTO 字段更新(新增 ABP 审计字段) | 否 |
| `src/Hua.Todo.Application/Data/TodoDbContext.cs` | 查询过滤器配置 | 否 |
| `src/Hua.Todo.Application/CloudSync/CloudTaskSyncService.cs` | 软删除处理逻辑 | 否 |
| `src/Hua.Todo.Application/Migrations/` | 新增迁移文件 | 否 |
| `src/Hua.Todo.Web/src/types/task.ts` | **重构(Guid 主键 + ABP 审计字段 + 类型修正)** | **前端共享** |
| `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/04-云同步规则.md` | 文档修正(字段命名) | 是(文档) |
| `.trae/rules/项目/03-数据模型与迁移约束.md` | 实体清单更新 | 是(规则) |
---
## 十三、与其他工单的边界
| 工单 | 边界 |
|---|---|
| 04-CloudSync-服务端基础能力 | 依赖本工单的 ABP 审计字段 |
| 05-CloudSync-客户端配置与工作流 | 依赖本工单的 DTO 字段更新 |
| 08-同源 Host 重构 | 无依赖,可并行 |
> **建议**:本工单与 04/05 存在依赖关系,建议在 04/05 之前完成,或合并为一个工单实现。
---
> **相关文档**
> - ABP 表设计规范:[2.表设计与字段命名规范.md](file:///d:/Codes/CodeSmith/CodeSmith/ShaoHua.CodeSmith.Abp/docs/2.表设计与字段命名规范.md)
> - 同步规则:[02-任务同步规则.md](../../manual/02-任务同步规则.md)
> - 数据模型约束:[03-数据模型与迁移约束.md](../../../.trae/rules/项目/03-数据模型与迁移约束.md)
@@ -0,0 +1,207 @@
# 研发工单 v1.3.0 - 总览
> 术语澄清:本文件中"研发工单"指智能体/开发者执行的**编码工作项**,与业务侧的 Todo 待办项无关。详见 [.trae/rules/全局/05-研发工单规则.md](../../../.trae/rules/全局/05-研发工单规则.md)。
---
## 一、背景与目标
Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 序号 | 能力 | 描述 |
|---|---|---|
| 1 | **MCP 服务映射** | 将现有 HTTP 服务转换为 MCPModel Context Protocol)服务,提升服务调用效率与可扩展性 |
| 2 | **语音控制与 AI 辅助** | 通过语音指令操作 Todo 待办项(CRUD + 子任务),通过 LLM 提供 AI 辅助任务拆分建议 |
| 3 | **会议任务拆分** | 以"会议"为入口记录会议内容(录音/文字),通过 AI 分析自动生成待办项拆分建议,用户确认后批量创建 |
---
## 二、工单拆分
### 2.1 并行工单(可同步执行)
| 工单编号 | 标题 | 负责人 | 状态 |
|---|---|---|---|---|---|---|
| 01 | HTTP 服务转换为 MCP 服务 | - | 已完成 |
| 02 | 语音控制与 AI 辅助 | - | 已完成 |
| 03 | 会议任务拆分 | - | 待开始 |
| 04 | 富文本描述、附件与外部链接 | - | 已完成 |
### 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 串行工单(依赖前置工单完成)
当前版本暂无串行工单依赖。
---
## 三、各子工单摘要
### 3.1 工单 01 - HTTP 服务转换为 MCP 服务
**目标**:基于 C# 现有映射框架,将 Hua.Todo 的 HTTP API 转换为 MCP 服务
**核心需求**
- 将现有的 HTTP API 端点映射为 MCP 工具描述符
- 生成符合 MCP 规范的契约文档
- 验证 MCP 服务的可用性与正确性
**验收标准**
- MCP 服务可正常对外提供接口
- 所有原有 HTTP API 功能在 MCP 服务中可正常使用
### 3.2 工单 02 - 语音控制与 AI 辅助
**目标**:实现语音控制 Todo 待办项能力与 AI 辅助任务拆分功能
**核心需求**
- 语音输入(STT):平台原生语音识别 → 文字
- 语音播报(TTS):执行结果语音反馈
- 语音指令解析与执行(CRUD + 子任务 + 歧义处理)
- AI 辅助任务拆分(LLM 生成子任务建议,用户确认后批量创建)
**不包含**
- 语音通话功能(场景不明确,本期不做)
**验收标准**
- 各平台 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)限制生效
---
## 四、待验证表
| 子工单 | 验证项 | 状态 | 备注 |
|---|---|---|---|
| 01 | MCP 服务契约文档生成 | 已验证 | 动态工具自动生成,工具名/描述/参数 schema 均已覆盖 |
| 01 | MCP 服务可用性测试 | 已验证 | 17 个单元测试全部通过(含工具调用端到端验证) |
| 01 | 原有 API 功能兼容性 | 已验证 | 所有 ITaskService 方法均生成 MCP 工具,覆盖 CRUD 全部 9 个 API |
| 01 | CloudSync MCP 工具 | 已知缺口 | CloudSync 服务未实现 IDynamicApiService,需手动映射或重构 |
| 02 | STT/TTS 平台适配 | 待验证 | Windows 优先,其他平台后续 |
| 02 | 语音指令识别准确率 | 待验证 | - |
| 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 | 跨平台编辑安全(移动端不覆盖描述/附件) | 待验证 | 桌面设值 → 移动端改标题 → 桌面验证不丢失 |
---
## 五、关键决策
| 决策点 | 结论 |
|---|---|
| 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 后续追加 |
---
## 六、依赖与前置条件
| 依赖项 | 状态 | 来源 |
|---|---|---|
| TRAE MCP SDK | 已就绪 | 平台内置 |
| 各平台原生 STT/TTS API | 已就绪 | 平台内置 |
| Hua.Todo v1.2.0 | 已完成 | 上一版本 |
| LLM APIAI 拆分) | 待确认 | Host 端调用 |
| 浏览器 MediaRecorder API | 已就绪 | 前端录音 |
| 工单 02 LlmClientService | 待实现 | 会议拆分复用 |
---
## 七、风险与回滚
| 风险 | 影响 | 应对策略 |
|---|---|---|
| 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
@@ -0,0 +1,104 @@
# 研发工单 v1.3.0 - 01 HTTP 服务转换为 MCP 服务
---
## 一、目标与范围
### 1.1 目标
基于 C# 现有映射框架,将 Hua.Todo 的 HTTP API 转换为 MCPModel Context Protocol)服务,提升服务调用效率与可扩展性。
### 1.2 范围
**包含**
- 任务管理 API 的 MCP 映射
- 云同步 API 的 MCP 映射
- MCP 服务契约文档生成
- MCP 服务可用性验证
**不包含**
- 新增业务功能开发
- 前端 UI 修改
---
## 二、前置条件
| 条件 | 说明 |
|---|---|
| Hua.Todo v1.2.0 | 已完成,提供基础 HTTP API |
| TRAE MCP SDK | 平台内置,已就绪 |
| 现有 API 文档 | 参考 [README.md](../../../README.md) |
---
## 三、需求规格
### 3.1 MCP 服务描述符生成
为以下 HTTP API 端点生成 MCP 工具描述符:
#### 任务管理 API
| HTTP 端点 | MCP 工具名 | 功能描述 |
|---|---|---|
| `GET /api/task` | `getTaskList` | 获取任务列表 |
| `GET /api/task/{id}` | `getTaskById` | 获取单个任务 |
| `POST /api/task` | `createTask` | 创建任务 |
| `PUT /api/task` | `updateTask` | 更新任务 |
| `PATCH /api/task/{id}/toggle` | `toggleTaskStatus` | 切换完成状态 |
| `DELETE /api/task/{id}` | `deleteTask` | 删除任务 |
| `GET /api/task/{parentTaskId}/subtasks` | `getSubtasks` | 获取子任务列表 |
#### 云同步 APIHost 模式)
| HTTP 端点 | MCP 工具名 | 功能描述 |
|---|---|---|
| `POST /auth/login` | `cloudLogin` | 用户登录 |
| `POST /auth/logout` | `cloudLogout` | 用户注销 |
| `GET /tasks/` | `getCloudTasks` | 获取云端任务 |
| `POST /sync/` | `syncTasks` | 推送/拉取合并同步 |
| `GET /security/policy` | `getSecurityPolicy` | 获取安全策略 |
### 3.2 契约文档生成
生成符合 MCP 规范的 JSON 契约文档,包含:
- 工具名称
- 参数定义(名称、类型、是否必填)
- 返回值定义
- 错误处理说明
### 3.3 服务注册与验证
- 按照平台标准流程注册 MCP 服务
- 验证所有工具的调用可用性
---
## 四、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| MCP 服务注册 | 检查服务列表 | 服务成功注册 |
| 工具描述符生成 | 查看 JSON 输出 | 所有工具描述符生成完整 |
| getTaskList | 调用工具 | 返回任务列表 |
| createTask | 调用工具 | 创建成功,返回任务 ID |
| updateTask | 调用工具 | 更新成功 |
| deleteTask | 调用工具 | 删除成功 |
| cloudLogin | 调用工具 | 登录成功,返回 Token |
| syncTasks | 调用工具 | 同步成功 |
---
## 五、Touch List
| 文件路径 | 修改类型 | 说明 |
|---|---|---|
| `src/Hua.Todo.Application/Mcp/` | 新增 | MCP 服务实现目录 |
| `src/Hua.Todo.Application/Mcp/McpServiceCollectionExtensions.cs` | 新增 | MCP 服务注册扩展 |
| `src/Hua.Todo.Application/Mcp/TaskMcpService.cs` | 新增 | 任务管理 MCP 服务 |
| `src/Hua.Todo.Application/Mcp/CloudSyncMcpService.cs` | 新增 | 云同步 MCP 服务 |
| `src/Hua.Todo.Application/Mcp/Models/` | 新增 | MCP DTO 模型 |
---
**工单编号**01
**标题**HTTP 服务转换为 MCP 服务
**版本**v1.3.0
@@ -0,0 +1,453 @@
# 研发工单 v1.3.0 - 02 语音控制与 AI 辅助
---
## 一、目标与范围
### 1.1 目标
实现语音控制 Todo 待办项能力与 AI 辅助任务拆分功能,通过平台原生 STT/TTS 实现语音输入输出,通过 LLM 意图解析处理自然语言指令,离线降级到规则匹配,通过 LLM 提供 AI 拆分建议。
### 1.2 范围
**包含**
- 语音输入(STT):平台原生语音识别 → 文字
- 语音播报(TTS):执行结果语音反馈
- 语音指令解析与执行(CRUD + 子任务操作)
- 指令歧义处理(目标不唯一时返回候选列表)
- AI 辅助任务拆分(LLM 生成子任务建议,用户确认后批量创建)
**不包含**
- 语音通话功能(场景不明确,本期不做)
- 前端语音 UI 设计(仅提供能力层与 API)
- 第三方语音服务集成(使用平台原生能力)
---
## 二、前置条件
| 条件 | 说明 |
|---|---|
| Hua.Todo v1.2.0 | 已完成,提供基础业务能力 |
| 各平台原生 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 整体链路
```
平台 STT(语音→文字)
IVoiceInputServiceCore 接口,各平台实现)
↓ 回调文字到前端
WebView → POST /api/voice/command { text: "帮我把那个开会的删了吧" }
Host API → IVoiceIntentParser(双策略)
├─ 在线:LlmIntentParser(调 LLM,输出结构化意图+参数)
└─ 离线:RuleIntentParser(关键词规则匹配,覆盖高频指令)
意图 + 参数 → 判断歧义
├─ 无歧义 → 调用 TaskService 执行 → TTS 播报结果
├─ 有歧义 → 返回候选列表 → 前端展示 → 用户确认 → 再执行
└─ UNKNOWN → TTS 播报"没听懂,请再说一次"
```
### 3.2 STT/TTS 分层策略
遵循与全局快捷键相同的平台分离模式(接口+平台目录):
| 层 | 职责 | 位置 |
|---|---|---|
| `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
```
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,不要解释。
```
**示例调用**
```
输入:"帮我把那个开会的任务删了吧"
LLM 输出:
{
"intent": "DELETE",
"params": { "targetTitle": "开会" },
"confidence": 0.95
}
```
```
输入:"这个项目太大了,帮我拆一下"
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 工具
---
## 五、验收标准
| 验收项 | 验证方法 | 预期结果 |
|---|---|---|
| STT 语音输入 | 说话后检查回调文字 | 文字识别正确 |
| TTS 语音播报 | 执行指令后检查播报 | 播报内容与执行结果一致 |
| 创建任务(在线) | "帮我把开会的任务加上" | LLM 解析为 CREATE,成功创建 |
| 创建任务(离线降级) | "创建任务 测试" | 规则匹配为 CREATE,成功创建 |
| 创建带优先级任务 | "建一个高优先级任务 紧急" | 成功创建高优先级任务 |
| 完成任务指令 | "把那个开会的事标完成" | LLM 解析为 COMPLETE,执行成功 |
| 取消完成指令 | "取消完成 测试" | 任务状态恢复为未完成 |
| 删除任务指令 | "帮我把测试删了" | LLM 解析为 DELETE,删除成功 |
| 更新任务指令 | "把测试改成验收" | 任务标题更新 |
| 查询任务指令 | "有哪些没做完的" | LLM 解析为 QUERY,返回列表 |
| 添加子任务指令 | "给开会加个子任务 准备PPT" | 子任务创建成功 |
| 歧义处理 | 多个任务含"开会"时说"完成开会" | 返回候选列表,不直接执行 |
| AI 拆分建议 | "帮我拆分 开会" | 返回子任务建议列表 |
| AI 拆分确认 | 用户勾选后确认 | 仅创建勾选的子任务 |
| 离线 AI 拆分 | 离线时说"帮我拆分" | 播报"离线模式不支持 AI 拆分" |
| 无法识别指令 | 说出无关内容 | 播报"没听懂,请再说一次" |
| LLM 降级 | 断网时使用语音 | 自动降级到规则匹配,基本 CRUD 可用 |
---
## 六、Touch List
| 文件路径 | 修改类型 | 说明 |
|---|---|---|
| `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
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Hua.Todo AppRun script for AppImage
HERE=$(dirname $(readlink -f $0))
export LD_LIBRARY_PATH=$HERE/usr/lib:$LD_LIBRARY_PATH
export PATH=$HERE/usr/bin:$PATH
exec "$HERE/Hua.Todo.Avalonia" "$@"
+39
View File
@@ -0,0 +1,39 @@
# Hua.Todo Linux Packaging (v1.2.0+)
本项目提供 Linux 平台的交付产物支持。目前在 Windows 构建环境下产出 `.tar.gz` 压缩包。
## 1. AppImage 打包 (Recommended)
AppImage 是 Linux 下主流的自包含、即插即用发布格式。
### 前提条件
- 在 Linux (Ubuntu/Debian 等) 环境下执行。
- 安装 `appimagetool`
### 制作步骤
1. 解压 `hua.todo-{version}-linux-x64.tar.gz``AppDir` 目录。
2.`pack/linux/AppRun` 复制到 `AppDir/AppRun` 并赋予执行权限。
3.`pack/linux/hua.todo.desktop` 复制到 `AppDir/hua.todo.desktop`
4. 将图标 `src/Hua.Todo.Avalonia/icon.ico` (或 png 版本) 复制到 `AppDir/appicon.png`
5. 执行打包命令:
```bash
appimagetool AppDir/ Hua.Todo-x86_64.AppImage
```
## 2. Flatpak 打包
Flatpak 提供更好的沙盒隔离与应用商店分发支持。
### 前提条件
- 安装 `flatpak-builder`。
### 制作步骤
1. 根据 `pack/linux/com.hua.todo.json` (待完善) 的描述配置 manifest。
2. 使用 `flatpak-builder` 构建。
## 3. 直接分发 (.tar.gz)
这是目前 `publish-linux.ps1` 默认产出的格式。
1. 解压后直接运行 `Hua.Todo.Avalonia` 即可。
2. 依赖项:`libwebkit2gtk-4.0-37` (用于 WebView)。
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Hua.Todo
Comment=Hua.Todo - Cross-platform Todo App (Avalonia)
Exec=AppRun
Icon=appicon
Type=Application
Categories=Office;Utility;
Terminal=false
X-AppImage-Name=Hua.Todo
X-AppImage-Version=1.2.8
+111 -14
View File
@@ -6,22 +6,32 @@
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链
- Avalonia Linux 入口项目需先落地(参见 docs/project/研发工单-v1.2.0/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
#>
param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[string]$TargetFramework = "net10.0",
[switch]$SelfContained,
[string]$Configuration = "Release"
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Get-FirstExistingFilePath {
param(
@@ -41,10 +51,23 @@ function Get-FirstExistingFilePath {
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
[string]$ProjectFile,
[string]$DirectoryBuildProps
)
$currentVersion = "0.0.0"
# 1. Try Directory.Build.props first
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
}
# 2. Try .csproj
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
@@ -60,6 +83,38 @@ function Read-ProjectVersion {
return $currentVersion
}
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2 # Give OS more time to release file handles
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
if (!$SkipProcessStop.IsPresent) {
Stop-ProjectProcesses
}
$candidateProjects = @(
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj")
@@ -71,13 +126,14 @@ if ($null -eq $ProjectFile) {
Write-Error @"
Avalonia Linux Linux
docs/project/v1.2.0-tasks/01-Linux-AvaloniaWebView.md
docs/project/-v1.2.0/01-Linux-AvaloniaWebView.md
$candidatesText
"@
exit 1
}
$version = Read-ProjectVersion -ProjectFile $ProjectFile
$version = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier"
$publishDir = Join-Path $artifactRoot "publish"
@@ -88,16 +144,57 @@ New-Item -ItemType Directory -Path $publishDir | Out-Null
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
dotnet publish $ProjectFile `
-c $Configuration `
-r $RuntimeIdentifier `
--self-contained $selfContainedValue `
-o $publishDir
if (!$SkipRestore.IsPresent) {
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
# Ensure trailing slash and avoid backslash escaping the quote in CLI
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @restoreArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed"
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @cleanArgs
}
$maxAttempts = 3
$publishSuccess = $false
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Publishing (RID=$RuntimeIdentifier, TFM=$TargetFramework, SelfContained=$selfContainedValue, Config=$Configuration, Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$publishArgs = @("publish", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "--framework", $TargetFramework, "--self-contained", $selfContainedValue, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "-o", $publishDir)
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
}
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
$publishSuccess = $true
break
}
Write-Host $publishOutput
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
Stop-ProjectProcesses # Try killing processes again before retry
continue
}
Write-Error "dotnet publish failed after $attempt attempts."
exit 1
}
+151 -26
View File
@@ -1,32 +1,64 @@
param(
[ValidateSet("Maui", "Avalonia")]
[string]$AppType = "Maui",
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
[string]$RuntimeIdentifier = "win-x64",
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipInnoSetup,
[switch]$SkipVersionBump,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier))
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)),
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
if ($AppType -eq "Avalonia") {
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Avalonia"
$TargetFramework = "net10.0"
} else {
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
}
$ProjectFile = Get-ChildItem -Path $ProjectDir -Filter "*.csproj" | Select-Object -First 1 -ExpandProperty FullName
$SetupScript = Join-Path $ProjectDir "setup.iss"
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectFile
[string]$ProjectFile,
[string]$DirectoryBuildProps
)
$currentVersion = "0.0.0"
# 1. Try Directory.Build.props first
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
[xml]$props = Get-Content $DirectoryBuildProps -Raw
$versionNode = $props.SelectSingleNode("//Version")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
}
# 2. Try .csproj
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
@@ -153,14 +185,93 @@ function Copy-Directory {
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile
function Stop-ProjectProcesses {
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
dotnet build-server shutdown | Out-Null
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
$processesToKill = Get-Process | Where-Object {
$_.ProcessName -like "*Hua.Todo*" -or
$_.ProcessName -eq "MSBuild" -or
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
}
if ($processesToKill) {
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
foreach ($p in $processesToKill) {
try {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
}
}
Start-Sleep -Seconds 2 # Give OS more time to release file handles
} else {
Write-Host "No conflicting processes found." -ForegroundColor Green
}
}
if (!$SkipProcessStop.IsPresent) {
Stop-ProjectProcesses
}
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion
Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan
if ($Configuration -ne "Release") {
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
Write-Host ""
}
if (!$SkipRestore.IsPresent) {
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
# Ensure trailing slash and avoid backslash escaping the quote in CLI
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @restoreArgs
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
}
& dotnet @cleanArgs
}
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
$maxAttempts = 3
$publishSuccess = $false
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Publishing $ProjectBaseName (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$publishArgs = @("publish", $ProjectFile, "--framework", $TargetFramework, "-c", $Configuration, "-r", $RuntimeIdentifier, "--self-contained", "false", "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true")
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
}
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
$publishSuccess = $true
break
}
Write-Host $publishOutput
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
Stop-ProjectProcesses # Try killing processes again before retry
continue
}
Write-Error "MAUI build failed after $attempt attempts."
exit 1
}
@@ -192,6 +303,7 @@ if (!(Test-Path $desiredExePath)) {
$candidateExe = $null
$preferredCandidateNames = @(
"Hua.Todo.Maui.exe",
"Hua.Todo.Avalonia.exe",
"Hua.Todo.exe"
)
foreach ($name in $preferredCandidateNames) {
@@ -217,19 +329,21 @@ if (!(Test-Path $desiredExePath)) {
exit 1
}
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
if ($AppType -eq "Maui") {
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
if (!(Test-Path $mauiIndexPath)) {
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
exit 1
}
}
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
$publishWwwroot = Join-Path $publishDir "wwwroot"
if (Test-Path $publishWwwroot) {
Remove-Item -Recurse -Force $publishWwwroot
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
$installerPath = $null
if (!$SkipInnoSetup.IsPresent) {
@@ -255,6 +369,7 @@ if (!$SkipInnoSetup.IsPresent) {
# ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly).
$maxAttempts = 3
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Write-Host "Compiling installer (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
@@ -286,8 +401,7 @@ if (!$SkipInnoSetup.IsPresent) {
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
}
} else {
Write-Error "Inno Setup compiler not found"
exit 1
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
}
}
@@ -302,7 +416,8 @@ if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
}
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
$installerName = "hua.todo-$currentVersion-$RuntimeIdentifier-setup.exe"
$installerPrefix = if ($AppType -eq "Avalonia") { "hua.todo-avalonia" } else { "hua.todo-maui" }
$installerName = "$installerPrefix-$currentVersion-$RuntimeIdentifier-setup.exe"
Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force
}
}
@@ -312,12 +427,22 @@ if (!$SkipVersionBump.IsPresent) {
if ($versionMatch.Success) {
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
$content = Get-Content $ProjectFile -Raw
if ($content -match "<Version>[^<]*</Version>") {
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content -Encoding UTF8
} else {
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
# 1. Update Directory.Build.props if exists
if (Test-Path $DirectoryBuildProps) {
$propsContent = Get-Content $DirectoryBuildProps -Raw
if ($propsContent -match "<Version>[^<]*</Version>") {
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
Write-Host "Updated version in Directory.Build.props to $newVersion" -ForegroundColor Green
}
}
# 2. Update .csproj if it still has Version
$csprojContent = Get-Content $ProjectFile -Raw
if ($csprojContent -match "<Version>[^<]*</Version>") {
$csprojContent = $csprojContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $csprojContent -Encoding UTF8
Write-Host "Updated version in $ProjectBaseName.csproj to $newVersion" -ForegroundColor Green
}
} else {
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
+51 -52
View File
@@ -1,63 +1,62 @@
param(
[switch]$Windows,
[switch]$Linux,
[string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"),
[switch]$LinuxSelfContained,
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
[switch]$SkipWindowsVersionBump
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
$shouldPublishWindows = $Windows.IsPresent
$shouldPublishLinux = $Linux.IsPresent
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishWindows = $true
$shouldPublishLinux = $true
# Read version from project file
$currentVersion = "1.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
} else {
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
}
}
if ($shouldPublishWindows) {
if (!(Test-Path $publishWindowsScript)) {
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
# Update setup script version with current version before build
if (Test-Path $SetupScript) {
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"'
$versionFound = $true
break
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent
}
}
Write-Host "Building Hua.Todo.Maui (Release)..." -ForegroundColor Cyan
dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
exit 1
}
& $publishWindowsScript `
-Configuration $Configuration `
-SkipInnoSetup:$SkipWindowsInnoSetup `
-SkipVersionBump:$SkipWindowsVersionBump
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
if ($shouldPublishLinux) {
if (!(Test-Path $publishLinuxScript)) {
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
exit 1
}
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript `
-RuntimeIdentifier $rid `
-SelfContained:$LinuxSelfContained `
-Configuration $Configuration
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
& $ISCC $SetupScript
if ($LASTEXITCODE -eq 0) {
Write-Host "Setup package created successfully!" -ForegroundColor Green
} else {
Write-Error "Packaging failed"
}
} else {
Write-Error "Inno Setup compiler not found"
}
$versionParts = $currentVersion.Split(".")
$patch = [int]$versionParts[2] + 1
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
$content = Get-Content $ProjectFile -Raw
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content
+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
+78
View File
@@ -0,0 +1,78 @@
param(
[switch]$Init,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Install-CodeGraph {
Write-Host "[信息] 正在安装 codegraph…" -ForegroundColor Cyan
$npm = Get-Command npm -ErrorAction SilentlyContinue
if ($npm) {
Write-Host "[信息] 通过 npm 安装 @colbymchenry/codegraph…" -ForegroundColor Cyan
npm install -g @colbymchenry/codegraph
if ($LASTEXITCODE -eq 0) {
Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green
return
}
Write-Host "[警告] npm 安装失败,尝试使用官方安装脚本…" -ForegroundColor Yellow
}
Write-Host "[信息] 通过官方脚本安装…" -ForegroundColor Cyan
irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex
if ($LASTEXITCODE -ne 0) { throw "codegraph 安装失败" }
Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green
}
# 切换到上级目录(脚本所在目录的上级)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$parentDir = Split-Path -Parent $scriptDir
Push-Location $parentDir
Write-Host "[信息] 工作目录已切换到: $parentDir" -ForegroundColor Cyan
try {
$codegraph = Get-Command codegraph -ErrorAction Stop
Write-Host "[正常] 已检测到 codegraph CLI" -ForegroundColor Green
} catch {
Write-Host "[提示] 未找到 codegraph,将自动安装…" -ForegroundColor Yellow
Install-CodeGraph
}
if (-not (Test-Path ".\.codegraph")) {
Write-Host "[警告] 未找到 .codegraph/ 目录,正在执行初始化…" -ForegroundColor Yellow
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 初始化 + 索引构建完毕" -ForegroundColor Green
} elseif ($Init) {
Write-Host "[信息] 正在重新初始化…" -ForegroundColor Cyan
codegraph init -i
if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" }
Write-Host "[完成] 重新初始化 + 索引构建完毕" -ForegroundColor Green
} elseif ($Force) {
Write-Host "[信息] 正在执行全量重建索引…" -ForegroundColor Cyan
codegraph index --force
if ($LASTEXITCODE -ne 0) { throw "codegraph index --force 失败" }
Write-Host "[完成] 全量重建索引完毕" -ForegroundColor Green
} else {
Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan
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
}
Write-Host "`n--- CodeGraph 状态 ---" -ForegroundColor Cyan
codegraph status
if ($LASTEXITCODE -ne 0) {
Write-Host "[警告] codegraph status 返回异常" -ForegroundColor Yellow
}
Pop-Location
@@ -1,194 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
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("/step-up", StepUpAsync).AllowAnonymous();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).AllowAnonymous();
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
return app;
}
private static async Task<IResult> BootstrapAdminAsync(
BootstrapAdminRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken);
if (!ok)
{
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
}
return Results.Ok();
}
private static async Task<IResult> LoginAsync(
LoginRequest request,
CloudAuthService authService,
CancellationToken cancellationToken)
{
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken);
if (response == null)
{
return CloudApiErrors.Unauthorized("Invalid credentials.");
}
return Results.Json(response);
}
private static async Task<IResult> StepUpAsync(
StepUpRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("Password is required.");
}
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken);
if (!expiresAt.HasValue)
{
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
}
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
}
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();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
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();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken);
return Results.Json(policy);
}
}
@@ -1,54 +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>
/// 需要二次认证(step-up)。
/// </summary>
public static IResult SecondFactorRequired(string message = "Second factor required.")
=> Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", 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,108 +0,0 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步任务条目。
/// </summary>
public class CloudTaskItem
{
/// <summary>
/// 任务 ID(服务端分配)。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; }
/// <summary>
/// 父任务 ID(v1.2.0 同步可先不使用)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
/// <summary>
/// 新增或更新的任务列表。
/// </summary>
public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary>
/// 需要删除的任务 ID 列表。
/// </summary>
public List<int> Deletes { get; set; } = new();
}
/// <summary>
/// 任务 Upsert DTO。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为空表示新建。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
/// <summary>
/// 服务端时间(UTC)。
/// </summary>
public DateTime ServerTimeUtc { get; set; }
/// <summary>
/// 当前用户的任务全量。
/// </summary>
public List<CloudTaskItem> Tasks { get; set; } = new();
}
@@ -1,188 +0,0 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 云同步认证服务(登录、初始化管理员、二次认证)。
/// </summary>
public class CloudAuthService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="CloudAuthService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public CloudAuthService(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_rolePermissionMapper = rolePermissionMapper;
}
/// <summary>
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>是否初始化成功。</returns>
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return false;
}
var hasAnyCloudUser = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.Role != "local", cancellationToken);
if (hasAnyCloudUser)
{
return false;
}
var exists = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists)
{
return false;
}
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin"
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
_dbContext.Users.Add(user);
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// 用户名密码登录并创建会话。
/// </summary>
/// <param name="userName">用户名。</param>
/// <param name="password">密码。</param>
/// <param name="sessionTtl">会话有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录响应;失败则返回 null。</returns>
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (user == null || user.Role == "local")
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
return null;
}
var now = DateTime.UtcNow;
var expiresAt = now.Add(sessionTtl);
var session = new UserSessionEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
CreatedAtUtc = now,
ExpiresAtUtc = expiresAt
};
_dbContext.UserSessions.Add(session);
var hasPolicy = await _dbContext.SecurityPolicies
.AsNoTracking()
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
if (!hasPolicy)
{
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new LoginResponse
{
AccessToken = session.Id.ToString(),
ExpiresAtUtc = expiresAt,
UserId = user.Id,
Role = user.Role,
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList()
};
}
/// <summary>
/// 通过再输入口令提升会话权限(step-up)。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</param>
/// <param name="stepUpTtl">二次认证有效期。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效期截止时间;失败返回 null。</returns>
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return null;
}
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
{
return null;
}
var stepUpExpiresAt = now.Add(stepUpTtl);
session.StepUpExpiresAtUtc = stepUpExpiresAt;
await _dbContext.SaveChangesAsync(cancellationToken);
return stepUpExpiresAt;
}
}
@@ -1,128 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 云同步任务服务(按用户隔离)。
/// </summary>
public class CloudTaskSyncService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public CloudTaskSyncService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的任务全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>任务列表。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{
var tasks = await _dbContext.Tasks
.AsNoTracking()
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync(cancellationToken);
return tasks.Select(MapToItem).ToList();
}
/// <summary>
/// 执行同步(增改删),并返回最新全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="request">同步请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>同步响应。</returns>
public async Task<SyncResponse> SyncAsync(Guid userId, SyncRequest request, CancellationToken cancellationToken)
{
request ??= new SyncRequest();
if (request.Deletes.Count > 0)
{
var deleteIds = request.Deletes.Distinct().ToList();
var toDelete = await _dbContext.Tasks
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
.ToListAsync(cancellationToken);
if (toDelete.Count > 0)
{
_dbContext.Tasks.RemoveRange(toDelete);
}
}
if (request.Upserts.Count > 0)
{
foreach (var upsert in request.Upserts)
{
if (string.IsNullOrWhiteSpace(upsert.Title))
{
continue;
}
if (upsert.Id.HasValue)
{
var existing = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (existing != null)
{
existing.Title = upsert.Title.Trim();
existing.Priority = upsert.Priority;
existing.IsCompleted = upsert.IsCompleted;
existing.ParentTaskId = upsert.ParentTaskId;
existing.UpdatedAt = DateTime.UtcNow;
continue;
}
}
var entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = upsert.ParentTaskId
};
_dbContext.Tasks.Add(entity);
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new SyncResponse
{
ServerTimeUtc = DateTime.UtcNow,
Tasks = await GetTasksAsync(userId, cancellationToken)
};
}
private static CloudTaskItem MapToItem(TaskEntity task)
{
return new CloudTaskItem
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAtUtc = task.CreatedAt,
UpdatedAtUtc = task.UpdatedAt,
ParentTaskId = task.ParentTaskId
};
}
}
@@ -1,69 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Services;
/// <summary>
/// 安全策略服务(按用户隔离)。
/// </summary>
public class SecurityPolicyService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的安全策略。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>策略 DTO。</returns>
public async Task<SecurityPolicyDto> GetPolicyAsync(Guid userId, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies
.AsNoTracking()
.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
return new SecurityPolicyDto
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
};
}
/// <summary>
/// 更新指定用户的安全策略(覆盖式更新)。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="allowPersist">是否允许落盘。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的策略 DTO。</returns>
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
if (policy == null)
{
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync };
_dbContext.SecurityPolicies.Add(policy);
}
else
{
policy.AllowPersist = allowPersist;
policy.AllowSync = allowSync;
}
await _dbContext.SaveChangesAsync(cancellationToken);
return await GetPolicyAsync(userId, cancellationToken);
}
}
@@ -0,0 +1,70 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Hua.Todo.Application.Common.Converters;
/// <summary>
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601Round-trip)字符串形式持久化到 SQLite,
/// 并在读取时兼容历史遗留的 "ticks/Unix 时间戳" 数字字符串,避免因脏数据导致查询失败。
/// </summary>
public sealed class LenientUtcDateTimeStringConverter : ValueConverter<DateTime, string>
{
/// <summary>
/// 创建 UTC DateTime 与 SQLite TEXT 之间的转换器。
/// </summary>
public LenientUtcDateTimeStringConverter()
: base(
model => ConvertModelToProvider(model),
provider => ConvertProviderToModel(provider))
{
}
private static string ConvertModelToProvider(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return utc.ToString("O", CultureInfo.InvariantCulture);
}
private static DateTime ConvertProviderToModel(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
if (numeric >= DateTime.MinValue.Ticks && numeric <= DateTime.MaxValue.Ticks && numeric >= 10_000_000_000_000)
{
return new DateTime(numeric, DateTimeKind.Utc);
}
if (numeric >= 0 && numeric <= 253_402_300_799_999)
{
return DateTimeOffset.FromUnixTimeMilliseconds(numeric).UtcDateTime;
}
if (numeric >= 0 && numeric <= 253_402_300_799)
{
return DateTimeOffset.FromUnixTimeSeconds(numeric).UtcDateTime;
}
}
if (DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
}
@@ -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,105 +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>
public DbSet<UserEntity> Users { get; set; }
/// <summary>
/// 用户会话集合。
/// </summary>
public DbSet<UserSessionEntity> UserSessions { get; set; }
/// <summary>
/// 安全策略集合。
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { 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.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
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.User)
.WithMany(u => u.Tasks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserName).IsRequired().HasMaxLength(64);
entity.HasIndex(e => e.UserName).IsUnique();
entity.Property(e => e.PasswordHash).IsRequired();
entity.Property(e => e.Role).IsRequired().HasMaxLength(32);
});
modelBuilder.Entity<UserSessionEntity>(entity =>
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
entity.Property(e => e.CreatedAtUtc).IsRequired();
entity.Property(e => e.ExpiresAtUtc).IsRequired();
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.Property(e => e.AllowPersist).HasDefaultValue(true);
entity.Property(e => e.AllowSync).HasDefaultValue(true);
entity.HasIndex(e => e.UserId).IsUnique();
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -5,24 +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" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<!-- 移动端排除依赖 ASP.NET Core 的 CloudSync I/O 文件 -->
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="CloudSync\\**\\*.cs" />
<Compile Remove="Services\CloudSync\Services\CloudSyncProxyService.cs" />
<Compile Remove="Services\CloudSync\Services\CloudSyncServerService.cs" />
<Compile Remove="Services\CloudSync\Services\CloudSyncProxySettingsService.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<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,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");
}
}
}
@@ -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 />
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("20260406173734_AddAllowSyncToSecurityPolicy")]
partial class AddAllowSyncToSecurityPolicy
[Migration("20260616203619_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -20,6 +20,95 @@ 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")
.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<DateTime>("TimestampUtc")
.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")
@@ -36,6 +125,14 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -49,42 +146,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<DateTime>("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<DateTime>("UpdatedAt")
.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");
@@ -92,7 +229,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 =>
@@ -101,15 +238,28 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("MustChangePassword")
.HasColumnType("INTEGER");
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)
@@ -148,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")
@@ -190,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,6 +1,6 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -17,6 +17,95 @@ 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")
.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<DateTime>("TimestampUtc")
.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")
@@ -33,6 +122,14 @@ namespace Hua.Todo.Application.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.HasColumnType("INTEGER");
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -46,42 +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<DateTime>("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<DateTime>("UpdatedAt")
.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");
@@ -89,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 =>
@@ -98,15 +235,28 @@ namespace Hua.Todo.Application.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("MustChangePassword")
.HasColumnType("INTEGER");
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)
@@ -145,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")
@@ -187,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,8 +27,7 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId)
.Include(t => t.SubTasks)
.Include(t => t.SubTasks!)
.ToListAsync();
}
@@ -38,11 +36,11 @@ 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)
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
.Include(t => t.SubTasks!)
.FirstOrDefaultAsync(t => t.Id == id);
}
/// <summary>
@@ -52,8 +50,8 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreationTime)
.ToListAsync();
}
@@ -64,8 +62,8 @@ public class TaskRepository : ITaskRepository
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.LastModificationTime)
.ToListAsync();
}
@@ -76,7 +74,6 @@ public class TaskRepository : ITaskRepository
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
taskEntity.UserId = TodoUserIds.LocalUserId;
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
@@ -89,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;
@@ -100,9 +97,9 @@ 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.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
var task = await _context.Tasks.FindAsync(id);
if (task != null)
{
_context.Tasks.Remove(task);
@@ -115,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.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.Where(t => t.ParentTaskId == parentTaskId)
.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;
}
@@ -40,14 +40,5 @@ public static class ClaimsPrincipalExtensions
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 判断是否已完成二次认证(step-up)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>是否已完成二次认证。</returns>
public static bool HasStepUp(this ClaimsPrincipal user)
{
return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase));
}
}
@@ -1,4 +1,4 @@
namespace Hua.Todo.Application.CloudSync.Auth;
namespace Hua.Todo.Application.Services.CloudSync.Auth;
/// <summary>
/// 云同步鉴权使用的 Claim 名称约定。
@@ -10,9 +10,5 @@ public static class CloudClaims
/// </summary>
public const string Permission = "perm";
/// <summary>
/// 二次认证状态 Claim。
/// </summary>
public const string StepUp = "step_up";
}

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