Compare commits
10 Commits
8443d14ba6
...
cf96c56bed
| Author | SHA1 | Date | |
|---|---|---|---|
| cf96c56bed | |||
| 46db04e43e | |||
| 14868c45c7 | |||
| d81aa06681 | |||
| a7ba814833 | |||
| c54f2e2ecc | |||
| 3dbd97103c | |||
| 1f87565d5a | |||
| d94d1f36d2 | |||
| d53828c150 |
@@ -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
|
||||||
@@ -366,7 +366,3 @@ FodyWeavers.xsd
|
|||||||
/Hua.Todo/Output
|
/Hua.Todo/Output
|
||||||
/src/Hua.Todo.Maui/Output
|
/src/Hua.Todo.Maui/Output
|
||||||
/src/Hua.Todo.Host/Hua.Todo.db
|
/src/Hua.Todo.Host/Hua.Todo.db
|
||||||
/src/Hua.Todo.Host/Hua.Todo.db-shm
|
|
||||||
/src/Hua.Todo.Host/Hua.Todo.db-wal
|
|
||||||
/.artifacts/buildcheck
|
|
||||||
/.trae
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 协调目录(并行 solo 专用)
|
||||||
|
|
||||||
|
该目录用于解决两类问题:
|
||||||
|
|
||||||
|
- 文件冲突:多窗口并行时明确"谁是 Writer",其他人不直接改同一文件
|
||||||
|
- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进
|
||||||
|
|
||||||
|
## 目录约定
|
||||||
|
|
||||||
|
- `01-ownership.md`:文件/目录所有权登记(Writer 表)
|
||||||
|
- `02-shared-files.md`:本阶段共享文件清单(由 Integrator 维护)
|
||||||
|
- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘)
|
||||||
|
- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件)
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
- 所有权与共享文件清单优先使用"仓库相对路径"
|
||||||
|
- 禁止记录或提交构建产物目录中的文件路径(如 `bin/`、`obj/`、`node_modules/`、`dist/` 等)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 文件/目录所有权(Writer)登记
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 同一时段内,同一个文件只能有一个 Writer
|
||||||
|
- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘
|
||||||
|
- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码
|
||||||
|
|
||||||
|
## 当前所有权
|
||||||
|
|
||||||
|
> 状态:空(无进行中的并行任务)。新增并行任务时按下方表头格式追加记录行;任务验收后由 Integrator 清空。
|
||||||
|
|
||||||
|
| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 本阶段共享文件清单(Integrator 维护)
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 本文件只由 Integrator 修改,避免反复冲突
|
||||||
|
- 清单内每条必须是"仓库相对路径",并说明为什么共享(入口/协议/配置/依赖锁等)
|
||||||
|
- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator
|
||||||
|
|
||||||
|
## 共享文件
|
||||||
|
|
||||||
|
> 状态:空(无进行中的并行任务)。Integrator 在新阶段开始时按"路径:为什么共享"格式追加;阶段结束后清空。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 项目记忆(长期沉淀)
|
||||||
|
|
||||||
|
> 本文件保存**长期不变 / 不易频繁更新**的项目元信息。
|
||||||
|
> 当前实现进度、工单状态、未完结事项等"短期快照"请见 [.trae/rules/项目/04-即时状态记忆.md](../rules/项目/04-即时状态记忆.md)。
|
||||||
|
> 项目划分、依赖方向、运行模式见 [.trae/rules/项目/01-项目架构.md](../rules/项目/01-项目架构.md)。
|
||||||
|
|
||||||
|
## 关键依赖版本(含具体小版本号)
|
||||||
|
|
||||||
|
- **运行时**:.NET 10.0(SDK 10.0.201)
|
||||||
|
- **C# 语言**:C# 13
|
||||||
|
- **EF Core**:10.0(SQLite Provider)
|
||||||
|
- **API 文档**:Swashbuckle 10.1.7
|
||||||
|
- **前端**:Vue 3 / TypeScript 5 / Vite 5;Axios + Pinia
|
||||||
|
- **桌面**:Avalonia(Linux/Windows/macOS)
|
||||||
|
- **跨平台原生**:MAUI(Android/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,33 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 注释规范(必须遵守)
|
||||||
|
|
||||||
|
## 通用
|
||||||
|
|
||||||
|
- 新增或修改的代码必须包含足够注释,使“不了解该模块的人”也能理解其职责、边界与关键决策。
|
||||||
|
- 优先使用 **XML 文档注释**(`///`),而不是随意的行内注释。
|
||||||
|
- 不允许无意义注释(例如“初始化变量”“进入方法”)。注释必须解释“为什么/约束/边界/副作用”。
|
||||||
|
- 不允许出现“TODO/FIXME”但无上下文或无处理方案的注释。
|
||||||
|
|
||||||
|
## C#(.NET / MAUI)
|
||||||
|
|
||||||
|
- 所有 `public` / `protected` 的 **类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
|
||||||
|
- `summary`:一句话说明用途
|
||||||
|
- 对关键参数/返回值:`param` / `returns`
|
||||||
|
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
|
||||||
|
- 对 **跨平台逻辑**:
|
||||||
|
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
|
||||||
|
- 平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
|
||||||
|
- 对 **异步/后台任务**:
|
||||||
|
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
|
||||||
|
- 对 **安全/隐私**:
|
||||||
|
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
|
||||||
|
|
||||||
|
## TypeScript / Vue(前端)
|
||||||
|
|
||||||
|
- 对导出的函数/类型必须有注释,解释用途与输入输出。
|
||||||
|
- 对“与后端/MAUI 交互”的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 文档同步规范(必须遵守)
|
||||||
|
|
||||||
|
## 通用原则
|
||||||
|
|
||||||
|
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
|
||||||
|
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
|
||||||
|
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
|
||||||
|
- **协作友好(局部修改)**:当并行处理多个任务/需求时,更新文档应尽量只修改与本任务直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
|
||||||
|
|
||||||
|
## 更新范围
|
||||||
|
|
||||||
|
- **README.md**:
|
||||||
|
- 如果变更涉及核心功能点(Features)、安装步骤(Installation)、快速开始(Quick Start)或 API 端点(API Endpoints),必须同步更新。
|
||||||
|
- 变更涉及技术栈调整或项目结构变化时需更新。
|
||||||
|
- **docs/ 目录文档**:
|
||||||
|
- **接口变更**:若修改了 API,需同步更新 [技术设计文档](docs/技术设计文档.md) 中的接口部分。
|
||||||
|
- **功能新增/调整**:需在 [产品需求文档](docs/产品需求文档.md) 和 [技术栈与模块](docs/技术栈与模块.md) 中体现。
|
||||||
|
- **架构/模式变更**:需更新 [技术设计文档](docs/技术设计文档.md)。
|
||||||
|
- **代码规范**:若引入了新的编码模式或工具,需更新 [代码规范文档](docs/代码规范文档.md)。
|
||||||
|
- **版本记录**:所有非琐碎的变更必须在 [版本记录.md](docs/版本记录.md) 中添加记录。
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
1. [ ] 是否有新增的 API 端点?(更新 README 和技术设计文档)
|
||||||
|
2. [ ] 是否修改了现有的业务逻辑或数据结构?(更新技术设计文档)
|
||||||
|
3. [ ] 是否有新增的功能模块?(更新产品需求文档和技术栈说明)
|
||||||
|
4. [ ] 是否调整了开发环境或依赖?(更新 README)
|
||||||
|
5. [ ] 是否在 [版本记录.md](docs/版本记录.md) 中记录了本次变更?
|
||||||
|
6. [ ] 文档变更是否保持“局部修改”,只影响与本任务相关的段落/小节?(避免无关重排/改写)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 智能体记忆与规范同步规则(必须遵守)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(语义层面跨项目可复用),关注智能体如何维护记忆与同步规范,与具体项目业务无关。
|
||||||
|
>
|
||||||
|
> `.trae/` 整体目录结构与各子目录职责见 [.trae/索引.md](../../索引.md),本文件不再重复描述。
|
||||||
|
|
||||||
|
## 记忆存储
|
||||||
|
|
||||||
|
- 智能体的记忆必须存放在 `.trae/memory` 文件夹中
|
||||||
|
- 记忆应按对话日期或主题进行组织,便于后续查询和参考
|
||||||
|
- 记忆内容应包含对话历史、关键决策、重要代码片段和规范调整等信息
|
||||||
|
- **项目即时状态**(当前实现到哪一步、未完结事项、临时决策快照)应同步写入 `.trae/rules/项目/04-即时状态记忆.md`,便于其他智能体或开发者快速对齐
|
||||||
|
|
||||||
|
## 规范同步
|
||||||
|
|
||||||
|
- 每次对话中涉及到的语法或规范相关内容,必须同步整理到 `.trae/rules` 目录下的对应文件中
|
||||||
|
- **通用规范**(注释、文档同步、工单流程、并行冲突)→ 写入 `.trae/rules/全局/`
|
||||||
|
- **项目专属**(业务命名、架构边界、数据模型、即时状态)→ 写入 `.trae/rules/项目/`
|
||||||
|
- 若涉及到新的规范或规则,应创建新的规则文件进行记录
|
||||||
|
- 规范同步应及时、准确,确保规则文件能真实反映当前项目的编码规范和最佳实践
|
||||||
|
|
||||||
|
## 文件命名规则(强制)
|
||||||
|
|
||||||
|
`.trae/` 下所有子目录中**新增的文件必须沿用 `NN-名称.md` 序号格式**,否则视为不合规:
|
||||||
|
|
||||||
|
- **格式**:两位数字 + 连字符 + 中文/英文名称 + `.md`,例如 `08-XXX规范.md`
|
||||||
|
- **序号取值**:紧接当前目录已有最大序号 +1,不得跳号、不得重复
|
||||||
|
- **入口/索引文件例外**:`.trae/索引.md` 这类目录入口文件不带序号
|
||||||
|
- **重排禁止**:除非整体重构,否则不得重排已有文件的序号;新增只能追加在末尾
|
||||||
|
- **同步更新索引**:每次新增文件后,必须在 [.trae/索引.md](../../索引.md) 的对应章节同步追加该文件的链接与一句话职责说明
|
||||||
|
- **跨目录创建**:在 `coordination/`、`memory/` 下新增文件时同样适用本规则
|
||||||
|
|
||||||
|
### 各子目录当前最大序号速查
|
||||||
|
|
||||||
|
| 子目录 | 当前最大序号 | 下一个可用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `rules/全局/` | 07 | 08 |
|
||||||
|
| `rules/项目/` | 04 | 05 |
|
||||||
|
| `memory/` | 01 | 02 |
|
||||||
|
| `coordination/` | 02 | 03 |
|
||||||
|
|
||||||
|
## 实现要求
|
||||||
|
|
||||||
|
- 智能体应定期检查并更新规则文件,确保其与项目实际情况保持一致
|
||||||
|
- 当发现规范冲突或需要调整时,应及时记录并通知相关人员
|
||||||
|
- 记忆存储和规范同步应作为智能体的核心功能,贯穿于整个开发过程
|
||||||
|
|
||||||
|
## 路径规范
|
||||||
|
|
||||||
|
- 所有 Markdown 文档中不应使用绝对路径,应使用相对路径
|
||||||
|
- 相对路径应以项目根目录为基准,例如 `.trae/memory` 而非绝对路径
|
||||||
|
- 确保路径格式统一,使用正斜杠 (`/`) 作为路径分隔符,避免使用反斜杠 (`\`)
|
||||||
|
- 智能体在生成或修改文档时,应自动检查并替换绝对路径为相对路径
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 记忆存储规范
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(跨项目通用),约束 `.trae/memory/` 的使用方式。
|
||||||
|
|
||||||
|
## 存储结构
|
||||||
|
- 记忆文件应存放在 `.trae/memory` 文件夹中
|
||||||
|
- 避免使用与日期相关的文件名,使用通用的描述性文件名
|
||||||
|
- 文件命名采用 `NN-名称.md` 序号格式
|
||||||
|
- 记忆内容应包含对话历史、关键决策、重要代码片段和规范调整等信息
|
||||||
|
|
||||||
|
## 内容规范
|
||||||
|
- 记忆文件应保持简洁明了,重点记录重要的开发决策和规范变更
|
||||||
|
- 避免存储冗余信息,只记录对项目有价值的内容
|
||||||
|
- 定期清理过时的记忆文件,保持存储空间的合理使用
|
||||||
|
|
||||||
|
## 与"项目即时状态"的边界
|
||||||
|
- `.trae/memory/` 偏向**长期保留**的对话产物与决策记录
|
||||||
|
- `.trae/rules/项目/04-即时状态记忆.md` 偏向**当前快照**(实现进度、未完结事项),更新频率高
|
||||||
|
- 二者不要重复存放同一份信息;以"是否需要长期沉淀"为判定标准
|
||||||
|
|
||||||
|
## memory/ 生命周期
|
||||||
|
|
||||||
|
研发工单验收完成后,对 `memory/` 的处理遵循以下原则:
|
||||||
|
|
||||||
|
- **追加而非覆盖**:将本次工单中产生的、值得**长期沉淀**的内容(架构决策、关键避坑经验、引入的新依赖与版本)追加到对应文件
|
||||||
|
- **不存放过程信息**:实现进度、待办勾选、临时决策这些短期信息应留在 [04-即时状态记忆.md](../项目/04-即时状态记忆.md),不进 `memory/`
|
||||||
|
- **不删长期内容**:除非内容已被证伪或过时,否则不删除既有条目;过时内容用"已废弃 / 已被 XX 取代"的形式保留语义而非物理删除
|
||||||
|
- **新增文件序号化**:当主题足够独立时新建 `NN-名称.md`(序号紧接当前最大值),并在 [.trae/索引.md](../../索引.md) 的 `memory/` 章节同步追加链接
|
||||||
|
|
||||||
|
## 访问权限
|
||||||
|
- 记忆文件仅供开发团队内部参考使用
|
||||||
|
- 确保记忆文件中的敏感信息得到适当保护
|
||||||
|
- 遵循项目的版本控制和代码管理规范
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: 强制项目注释规范(C# / TypeScript):新增或修改代码必须补全必要注释,便于维护与跨平台开发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 注释规范(必须遵守)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(跨项目通用),针对 C# / TypeScript / Vue 代码的注释要求。
|
||||||
|
|
||||||
|
## 通用
|
||||||
|
|
||||||
|
- 新增或修改的代码必须包含足够注释,使"不了解该模块的人"也能理解其职责、边界与关键决策。
|
||||||
|
- 优先使用 **XML 文档注释**(`///`),而不是随意的行内注释。
|
||||||
|
- 不允许无意义注释(例如"初始化变量""进入方法")。注释必须解释"为什么/约束/边界/副作用"。
|
||||||
|
- 不允许出现"TODO/FIXME"但无上下文或无处理方案的注释。
|
||||||
|
|
||||||
|
## C#(.NET / MAUI)
|
||||||
|
|
||||||
|
- 所有 `public` / `protected` 的 **类、接口、方法、属性** 必须提供 XML 文档注释,至少包含:
|
||||||
|
- `summary`:一句话说明用途
|
||||||
|
- 对关键参数/返回值:`param` / `returns`
|
||||||
|
- 对异常或副作用:在 `summary` 中明确说明(例如会注册系统钩子/会启动后台服务)
|
||||||
|
- 对 **跨平台逻辑**:
|
||||||
|
- 禁止在同一文件内混写多个平台的大段 `#if` 实现;应优先使用 `partial`、接口与平台目录分离。
|
||||||
|
- 平台分离后的公共入口处必须说明"平台差异在哪里、默认实现是什么、为什么这么做"。
|
||||||
|
- 对 **异步/后台任务**:
|
||||||
|
- 必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入。
|
||||||
|
- 对 **安全/隐私**:
|
||||||
|
- 禁止在日志或注释中输出密钥、Token、用户隐私信息。
|
||||||
|
|
||||||
|
## TypeScript / Vue(前端)
|
||||||
|
|
||||||
|
- 对导出的函数/类型必须有注释,解释用途与输入输出。
|
||||||
|
- 对"与后端/MAUI 交互"的协议字段(例如全局变量、事件名)必须注释说明来源与约束。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: 强制文档同步规范:每次变更代码(如新增功能、修改接口、调整架构等)必须同步更新 README.md 和 docs 目录下的相关文档。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 文档同步规范(必须遵守)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
|
||||||
|
|
||||||
|
## 通用原则
|
||||||
|
|
||||||
|
- **代码即文档,文档随代码**:文档不是静态的,它必须真实反映当前代码的状态。
|
||||||
|
- **及时性**:在提交代码变更的同时(或紧随其后),必须完成相关文档的更新。
|
||||||
|
- **准确性**:确保文档中的示例代码、接口说明、安装步骤与实际代码完全一致。
|
||||||
|
- **协作友好(局部修改)**:当并行处理多个研发工单/需求时,更新文档应尽量只修改与本工单直接相关的段落/小节,避免对不相关内容做无意义的重排、改写或格式化;如必须调整非关联内容,应拆分为独立的变更说明清楚原因与影响范围。
|
||||||
|
|
||||||
|
> 术语澄清:本规范中"研发工单"指编码工作项;项目业务里的"任务/Todo 待办项"是用户域实体,二者不要混淆。详见 [05-研发工单规则.md](./05-研发工单规则.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,129 @@
|
|||||||
|
# 研发工单同步规则汇总(Dev Work Item Rules)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(跨项目通用),关注智能体如何拆分编码工作。
|
||||||
|
|
||||||
|
> ⚠️ 术语澄清(必读)
|
||||||
|
>
|
||||||
|
> 本项目存在两类"任务"概念,必须严格区分,避免命名混淆:
|
||||||
|
>
|
||||||
|
> | 术语 | 含义 | 适用范围 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | **研发工单(Dev Work Item)** | 智能体 / 开发者执行的**编码工作项**(拆分需求、并行开发、集成等) | 本规则文档的全部内容 |
|
||||||
|
> | **Todo 待办项(Todo Item)** | Hua.Todo 项目**业务领域**中用户创建的待办事项(数据库实体、API 资源、UI 列表项) | 业务代码、产品需求文档、技术设计文档 |
|
||||||
|
>
|
||||||
|
> 本文档中所有"研发工单 / 工单 / 子工单"均指**编码工作项**,与业务侧的 Todo 待办项无关。
|
||||||
|
> 在代码、文档与对话中,凡涉及编码侧拆分时,**必须使用"研发工单"或"工单"**,禁止再使用"任务"二字以避免与 Todo 待办项混淆。
|
||||||
|
>
|
||||||
|
> 业务侧由于历史原因仍保留 `Task` / `SubTask` 等代码标识符(API、实体、UI),这些属于 Todo 待办项语义,**不在本规范替换范围内**。
|
||||||
|
>
|
||||||
|
> 本汇总文件是 [06-研发工单拆分规范.md](./06-研发工单拆分规范.md) 与 [07-并行窗口冲突规约.md](./07-并行窗口冲突规约.md) 的对外索引,详细规则以这两份源文件为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、研发工单拆分规范
|
||||||
|
|
||||||
|
### 适用时机
|
||||||
|
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出**研发工单拆分文档**
|
||||||
|
|
||||||
|
### 输出要求
|
||||||
|
1. **先读完所有相关文档**:包括 `docs/`、`docs/project/` 下与本次需求相关的内容
|
||||||
|
2. **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据
|
||||||
|
3. **新增专属文件夹**:在 `docs/project` 下新建 `研发工单-<主题>-<日期或版本>` 文件夹
|
||||||
|
4. **可并行工单拆分**:能同步执行的工单必须拆到不同 Markdown 文件中
|
||||||
|
5. **文件带序号**:按执行顺序编号(`01-xxx.md`、`02-xxx.md`)
|
||||||
|
|
||||||
|
### 每个研发工单文件必须包含
|
||||||
|
- 目标 / 范围(做什么、不做什么)
|
||||||
|
- 前置条件(依赖哪些结论 / 接口 / 文档)
|
||||||
|
- 验收标准(可执行的验证点)
|
||||||
|
- 风险与回滚(如有)
|
||||||
|
|
||||||
|
### 子工单完成标记要求
|
||||||
|
- 子工单完成后,必须在 `00-工单总览.md` 中标注"已完成"
|
||||||
|
- 维护"待验证表",记录每个子工单的"待验证 / 已验证"状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、并行窗口冲突规约
|
||||||
|
|
||||||
|
### 核心原则
|
||||||
|
1. **先声明后修改**:修改前先声明 Touch List 与共享文件策略
|
||||||
|
2. **文件所有权唯一**:同一时段内一个文件只能由一个窗口修改
|
||||||
|
3. **共享文件单点修改**:高耦合 / 共享入口的改动集中到集成窗口完成
|
||||||
|
4. **绿线优先**:任何可落盘的变更必须保持可编译
|
||||||
|
|
||||||
|
### Touch List 要求
|
||||||
|
- 精确到文件路径
|
||||||
|
- 标注修改类型(新增 / 小改 / 重构 / 接口变更 / 配置变更)
|
||||||
|
- 标注是否为共享文件
|
||||||
|
- 使用相对路径:`src\<module>\<file>`
|
||||||
|
|
||||||
|
### 共享文件判定标准(满足其一即为共享)
|
||||||
|
- 项目入口 / 启动逻辑、依赖注入注册、全局路由
|
||||||
|
- 公共配置、公共协议与 DTO、公共组件 / 样式
|
||||||
|
- 解决方案文件(`.sln`、`.csproj`)、锁文件、全局配置
|
||||||
|
|
||||||
|
### Writer 约束
|
||||||
|
- 非 Writer 窗口不得编辑共享文件
|
||||||
|
- 非 Writer 只能提供"差异建议"给 Writer 落盘
|
||||||
|
|
||||||
|
### 协调目录
|
||||||
|
固定目录:`.trae\coordination\`
|
||||||
|
- `01-ownership.md`:文件所有权登记表
|
||||||
|
- `02-shared-files.md`:共享文件清单(集成窗口维护)
|
||||||
|
- `handoff\`:差异建议 / 交接说明
|
||||||
|
- `wip\`:编译中途状态说明
|
||||||
|
|
||||||
|
### 编译绿线规则
|
||||||
|
- 不得提交破坏编译的变更
|
||||||
|
- 临时隔离手段(按优先级):
|
||||||
|
1. 新功能先放在新文件中,不在入口路径启用
|
||||||
|
2. 通过显式开关控制,默认关闭
|
||||||
|
3. 通过依赖注入分支或特性开关隔离
|
||||||
|
- 接口演进采用"双写 / 兼容期"策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、推荐文档结构
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/project/研发工单-<主题>-<版本>/
|
||||||
|
├── 00-工单总览.md # 背景、目标、关键决策、并行分组、待验证表
|
||||||
|
├── 01-并行工单A.md
|
||||||
|
├── 02-并行工单B.md
|
||||||
|
└── 03-串行工单C.md
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:上述目录与文件名中的"工单"指**研发工单**,与业务侧 Todo 待办项无关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、检查清单
|
||||||
|
|
||||||
|
### 研发工单拆分检查
|
||||||
|
1. [ ] 是否已阅读完所有相关文档?
|
||||||
|
2. [ ] 是否在 `docs/project` 下新建了专属文件夹(命名以"研发工单-"开头)?
|
||||||
|
3. [ ] 是否产出 `00-工单总览.md`?
|
||||||
|
4. [ ] 是否将可并行工单拆分为不同 md 文件?
|
||||||
|
5. [ ] 是否所有 md 文件都带有连续序号?
|
||||||
|
6. [ ] 子工单完成后是否在总览中标注"已完成"并更新待验证表?
|
||||||
|
|
||||||
|
### 并行冲突检查
|
||||||
|
1. [ ] 每个研发工单 md 是否已写 Touch List(精确到文件)?
|
||||||
|
2. [ ] Touch List 中的共享文件是否指定了唯一 Writer?
|
||||||
|
3. [ ] 是否避免了对共享文件的无意义格式化 / 重排?
|
||||||
|
4. [ ] 当前改动是否保持可编译(绿线)?
|
||||||
|
5. [ ] 若涉及接口演进,是否采用兼容期策略?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、与业务侧 Todo 待办项的边界
|
||||||
|
|
||||||
|
- 代码、注释、提交信息中描述**编码工作**时:使用"研发工单 / 工单 / 子工单"
|
||||||
|
- 代码、注释、提交信息中描述**业务功能**时:使用"Todo 待办项 / Todo Item / 父子任务(业务实体)"
|
||||||
|
- 文档命名前缀:
|
||||||
|
- 编码侧:`研发工单-<主题>-<版本>/`
|
||||||
|
- 业务侧(如有):遵循 `docs/` 既有命名习惯,禁止使用"研发工单"前缀
|
||||||
|
- 提交信息示例:
|
||||||
|
- ✅ `feat(todo): 新增 Todo 待办项截止日期字段(研发工单 02-后端模型)`
|
||||||
|
- ❌ `feat: 完成任务 02`("任务"歧义,禁用)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# 研发工单拆分输出规范(必须遵守)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
|
||||||
|
|
||||||
|
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
|
||||||
|
> 详见 [05-研发工单规则.md](./05-研发工单规则.md)。
|
||||||
|
> 凡涉及编码侧拆分时,**必须使用「研发工单」或「工单」**,禁止使用「任务」二字以避免与 Todo 待办项混淆。
|
||||||
|
|
||||||
|
## 适用时机
|
||||||
|
|
||||||
|
- 当需求需要先通读项目/产品/技术文档再开始实现时,必须先输出研发工单拆分文档,再开始写代码或改配置。
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
- **先读完所有相关文档**:包括但不限于 `docs/`、`docs/project/` 下与本次需求相关的内容。
|
||||||
|
- **先写工单拆分,再动手实现**:研发工单拆分产出是后续执行的入口与对齐依据。
|
||||||
|
- **新增一个专属文件夹**:在 `docs/project` 下新建一个文件夹存放本次研发工单拆分文档。
|
||||||
|
- 文件夹命名建议:`研发工单-<主题>-<日期或版本>`(保持可检索、避免与既有文档冲突)。
|
||||||
|
- **可并行的工单要拆成不同 md**:能同步执行(相互无依赖/弱依赖)的工单,必须拆到不同的 Markdown 文件中,便于并行推进与分工。
|
||||||
|
- **文件必须带序号**:同一文件夹下的 md 文件按执行顺序编号,序号从小到大。
|
||||||
|
- 文件名建议:`01-xxx.md`、`02-xxx.md`、`03-xxx.md`。
|
||||||
|
- **每个研发工单文件至少包含**:
|
||||||
|
- 目标/范围(做什么、不做什么)
|
||||||
|
- 前置条件(依赖哪些结论/接口/文档)
|
||||||
|
- 验收标准(怎么判断完成,包含可执行的验证点)
|
||||||
|
- 风险与回滚(如有)
|
||||||
|
- **子工单完成后的标记要求**:
|
||||||
|
- 当任一子工单(例如 `01-*`/`02-*`/`03-*`)完成实现后,必须在对应版本的 `00-工单总览.md` 中同步标注"已完成"。
|
||||||
|
- 同时必须维护一张"待验证表"(可用 Markdown 表格),对每个子工单给出"待验证/已验证"状态,避免实现完成但验收未闭环。
|
||||||
|
- **并行冲突规避要求**:当工单会被分发到多个 solo 窗口并行推进时,每个研发工单文件必须额外包含:
|
||||||
|
- 触碰文件清单(Touch List,精确到文件)
|
||||||
|
- 共享文件策略(哪些是共享文件、唯一 Writer 是谁、如何与集成窗口对接)
|
||||||
|
- 编译绿线策略(如何确保阶段性交付不破坏编译)
|
||||||
|
|
||||||
|
## 推荐结构(模板)
|
||||||
|
|
||||||
|
- `00-工单总览.md`
|
||||||
|
- 背景与目标
|
||||||
|
- 关键决策与约束
|
||||||
|
- 并行分组说明(哪些文件可同步做)
|
||||||
|
- 待验证表(每个子工单的"待验证/已验证"状态)
|
||||||
|
- `01-<并行工单A>.md`
|
||||||
|
- `02-<并行工单B>.md`
|
||||||
|
- `03-<串行工单C>.md`
|
||||||
|
|
||||||
|
## 最小检查清单
|
||||||
|
|
||||||
|
1. [ ] 是否确认已阅读完所有相关文档?
|
||||||
|
2. [ ] 是否在 `docs/project` 下新建了本次专属文件夹(命名以"研发工单-"开头)?
|
||||||
|
3. [ ] 是否产出 `00-工单总览.md`(或等价总览文件)?
|
||||||
|
4. [ ] 是否将可并行工单拆分为不同 md 文件?
|
||||||
|
5. [ ] 是否所有 md 文件都带有连续序号?
|
||||||
|
6. [ ] 并行工单是否为每个研发工单文件补充了 Touch List/共享文件策略/编译绿线策略?
|
||||||
|
7. [ ] 子工单完成后,是否在对应版本的 `00-工单总览.md` 标注"已完成",并在"待验证表"里更新状态?
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: false
|
||||||
|
description:
|
||||||
|
---
|
||||||
|
# 并行 solo 窗口冲突规约(必须遵守)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **全局规则**(跨项目通用)。
|
||||||
|
|
||||||
|
> ⚠️ 术语澄清:本规范中的「研发工单(Dev Work Item)」专指智能体 / 开发者执行的**编码工作项**,与 Hua.Todo 项目业务领域中的「Todo 待办项」是两个完全不同的概念。
|
||||||
|
> 详见 [05-研发工单规则.md](./05-研发工单规则.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,75 @@
|
|||||||
|
# 项目架构(Hua.Todo 专属)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
|
||||||
|
>
|
||||||
|
> 描述当前仓库的项目划分、依赖方向、运行模式与跨平台策略,供智能体在做改动时快速对齐架构边界。
|
||||||
|
> 完整设计参见 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md) 与 [docs/manual/03-技术栈与模块.md](../../../docs/manual/03-技术栈与模块.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 不得引用 Application;Application 不得引用任何宿主项目。
|
||||||
|
- **客户端不暴露云同步端点**: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)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 业务命名规范(Hua.Todo 专属)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
|
||||||
|
>
|
||||||
|
> 本文档规定 **业务实体**(Todo 待办项相关)与 **编码工作项**(研发工单)在代码、文档、提交信息中的命名边界。
|
||||||
|
> 全局术语规则参见 [.trae/rules/全局/05-研发工单规则.md](../全局/05-研发工单规则.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/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.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` 内部使用
|
||||||
|
- **DTO(Models)**:跨进程边界(HTTP API、WebView 注入)使用,位于 `Hua.Todo.Application/CloudSync/Models/` 与 `Hua.Todo.Application/Models/`
|
||||||
|
- **不要把实体直接序列化为 API 响应**:避免暴露内部字段、避免循环引用
|
||||||
|
|
||||||
|
## 六、检查清单(schema 变更)
|
||||||
|
|
||||||
|
1. [ ] 是否新增了 EF Core 迁移(而非手改快照)?
|
||||||
|
2. [ ] 迁移名称是否以 `AddXxx`/`UpdateXxx`/`RemoveXxx` 开头?
|
||||||
|
3. [ ] 是否在 [docs/manual/01-技术设计文档.md](../../../docs/manual/01-技术设计文档.md) 中同步更新数据模型描述?
|
||||||
|
4. [ ] 是否在 [docs/manual/06-版本记录.md](../../../docs/manual/06-版本记录.md) 中追加非琐碎变更条目?
|
||||||
|
5. [ ] 是否在嵌入式宿主上验证了启动时 Migrate 不报错?
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 即时状态记忆(Hua.Todo 专属)
|
||||||
|
|
||||||
|
> 适用范围:本规则属于 **项目规则**(仅 Hua.Todo 项目生效)。
|
||||||
|
>
|
||||||
|
> 本文档保存"当前实现到哪一步、未完结事项、临时决策"等**短期快照**,用于跨会话/跨智能体快速对齐。
|
||||||
|
> 与 [.trae/memory/](../../memory) 的差别:memory 偏长期沉淀,此处偏即时状态,更新频率高。
|
||||||
|
>
|
||||||
|
> **维护要求**:智能体每完成一个研发工单或观察到状态变化时,必须更新本文件的对应章节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、当前活跃版本
|
||||||
|
|
||||||
|
- **进行中版本**:v1.2.0
|
||||||
|
- **研发工单总览**:[docs/project/研发工单-v1.2.0/00-工单总览.md](../../../docs/project/研发工单-v1.2.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 |
|
||||||
|
|
||||||
|
## 三、关键临时决策
|
||||||
|
|
||||||
|
- **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 集成测试)
|
||||||
|
|
||||||
|
## 五、最近一次重大重构(如有)
|
||||||
|
|
||||||
|
- **术语统一与目录中文化**(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` 不带序号(入口文件)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **更新约定**:每次智能体修改本文件时,必须更新顶部的"进行中版本"和"工单状态快照"两节,确保信息不过时。
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# .trae 目录索引
|
||||||
|
|
||||||
|
> 本文件是 `.trae/` 目录的总入口,描述各子目录与关键文件的职责,便于智能体与开发者快速定位。
|
||||||
|
>
|
||||||
|
> 关于 TRAE 官方"全局规则 vs 项目规则":TRAE IDE 的"全局规则"由设置中心保存到用户级、**不入仓**;本仓库 `.trae/rules/` 下的所有内容(含子目录)都会被 TRAE 识别为**项目规则**。下面的子目录 `全局/` 与 `项目/` 是项目内部的**语义分组**,不改变 TRAE 的加载语义。
|
||||||
|
>
|
||||||
|
> 子目录嵌套深度受 TRAE 官方限制:`.trae/rules/` 下最多 3 层嵌套。
|
||||||
|
>
|
||||||
|
> 文件命名约定:每个子目录内文件以 `NN-名称.md` 格式编号(两位数字),编号反映**阅读优先级 / 依赖顺序**。索引文件本身不带序号。
|
||||||
|
>
|
||||||
|
> ⚠️ **新增文件必须遵守命名规则**:序号 = 当前目录最大值 + 1,不得跳号或重复,并需同步更新本索引文件中的对应章节。详见 [01-智能体记忆.md](./rules/全局/01-智能体记忆.md#文件命名规则强制)。
|
||||||
|
>
|
||||||
|
> ⚠️ **任务完成后清理义务**:研发工单验收完成后,必须清理 `coordination/` 中的临时记录行(保留文件与表头),并按需追加 `memory/` 的长期记忆条目。详见 [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md#任务验收后-coordination-清理) 与 [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md#memory-生命周期)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 整体结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.trae/
|
||||||
|
├── 索引.md ← 本文件(目录入口)
|
||||||
|
├── rules/
|
||||||
|
│ ├── 全局/ ← 通用规范(跨项目可复用的方法论)
|
||||||
|
│ └── 项目/ ← Hua.Todo 专属(业务/架构/状态/数据)
|
||||||
|
├── memory/ ← 长期记忆数据(决策、对话产物)
|
||||||
|
└── coordination/ ← 多 solo 窗口协作目录(运行时登记表)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## rules/全局/ — 通用规范(与具体项目无关)
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| [01-智能体记忆.md](./rules/全局/01-智能体记忆.md) | 智能体记忆/规范同步机制(**不含目录总览,由本索引文件承担**) |
|
||||||
|
| [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md) | `.trae/memory/` 的使用方式 |
|
||||||
|
| [03-注释规范.md](./rules/全局/03-注释规范.md) | C# / TypeScript / Vue 代码注释要求 |
|
||||||
|
| [04-文档同步规范.md](./rules/全局/04-文档同步规范.md) | 代码变更同步 README/docs 的硬性要求 |
|
||||||
|
| [05-研发工单规则.md](./rules/全局/05-研发工单规则.md) | 研发工单术语与边界(编码工作项 vs Todo 待办项) |
|
||||||
|
| [06-研发工单拆分规范.md](./rules/全局/06-研发工单拆分规范.md) | 工单拆分目录与文件结构 |
|
||||||
|
| [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.md) | 并行 solo 窗口下的 Touch List / Writer / 绿线策略 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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) | 当前活跃版本 / 工单状态快照 / 临时决策 / 未完结事项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## memory/ — 长期记忆
|
||||||
|
|
||||||
|
- 用途:长期沉淀的对话产物、关键开发决策、不再频繁更新的项目快照
|
||||||
|
- 与"项目即时状态"的边界:[04-即时状态记忆.md](./rules/项目/04-即时状态记忆.md) 偏当前快照、更新频率高;`memory/` 偏长期保留
|
||||||
|
- 维护规约:详见 [02-记忆存储规范.md](./rules/全局/02-记忆存储规范.md)
|
||||||
|
- 文件:
|
||||||
|
- [01-project_memory.md](./memory/01-project_memory.md):依赖小版本号、历史决策、变更时间轴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## coordination/ — 多窗口协作运行时目录
|
||||||
|
|
||||||
|
- 用途:并行 solo 窗口下的"谁在改什么"登记表与共享文件清单(**运行时数据,非冷文档**)
|
||||||
|
- 协作协议详见 [07-并行窗口冲突规约.md](./rules/全局/07-并行窗口冲突规约.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/`:差异建议交接 / 编译中途状态(按需新建)
|
||||||
Vendored
+2
@@ -20,6 +20,8 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"publish",
|
"publish",
|
||||||
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
|
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
|
||||||
|
"-c",
|
||||||
|
"Release",
|
||||||
"/property:GenerateFullPaths=true",
|
"/property:GenerateFullPaths=true",
|
||||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<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>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup Condition="'$(TargetFramework)' != ''">
|
<PropertyGroup Condition="'$(TargetFramework)' != ''">
|
||||||
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
|
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RID(win-*/linux-*/osx-*)时强制禁用,避免还原阶段解析对应的 Mono runtime pack。 -->
|
||||||
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
|
<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)">true</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>
|
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+3
-2
@@ -6,8 +6,9 @@
|
|||||||
<Platform Name="x86" />
|
<Platform Name="x86" />
|
||||||
</Configurations>
|
</Configurations>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj" />
|
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj">
|
||||||
<Project Path="src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj" />
|
<Build Solution="Debug|*" Project="true" />
|
||||||
|
</Project>
|
||||||
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
|
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
|
||||||
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
|
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
|
||||||
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
|
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
# Hua.Todo 跨平台代办管理应用
|
# 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 数据库保存数据,支持完全离线使用
|
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
|
||||||
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
|
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
|
||||||
- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示)
|
|
||||||
|
|
||||||
## 📦 安装与使用
|
## 📦 安装与使用
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ dotnet restore
|
|||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
API 将在 `http://localhost:5173` 启动
|
API 将在 `http://localhost:5173` 启动
|
||||||
开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)
|
|
||||||
|
|
||||||
#### 3. 启动前端 Web
|
#### 3. 启动前端 Web
|
||||||
```bash
|
```bash
|
||||||
@@ -48,27 +46,6 @@ npm run dev
|
|||||||
```
|
```
|
||||||
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
|
前端将在 `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/
|
Hua.Todo/
|
||||||
├── pack/ # 打包与交付产物(Linux/安装包等)
|
|
||||||
├── docs/ # 文档目录
|
├── docs/ # 文档目录
|
||||||
│ ├── manual/ # 用户/开发者手册
|
│ ├── manual/ # 用户/开发者手册
|
||||||
│ └── project/ # 项目进度/需求文档
|
│ └── project/ # 项目进度/需求文档
|
||||||
@@ -90,22 +66,18 @@ Hua.Todo/
|
|||||||
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
|
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
|
||||||
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
|
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
|
||||||
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
|
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
|
||||||
│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux)
|
|
||||||
│ └── Hua.Todo.slnx # 解决方案文件
|
│ └── Hua.Todo.slnx # 解决方案文件
|
||||||
├── .gitignore # Git 忽略文件
|
├── .gitignore # Git 忽略文件
|
||||||
└── README.md # 项目说明文档
|
└── README.md # 项目说明文档
|
||||||
```
|
```
|
||||||
|
|
||||||
### API 端点
|
### API 端点
|
||||||
- `GET /api/task` - 获取任务列表(默认:全部)
|
- `GET /api/tasks` - 获取任务列表
|
||||||
- `GET /api/task/active` - 获取未完成任务
|
- `GET /api/tasks/{id}` - 获取单个任务
|
||||||
- `GET /api/task/completed` - 获取已完成任务
|
- `POST /api/tasks` - 创建任务
|
||||||
- `GET /api/task/{id}` - 获取单个任务
|
- `PUT /api/tasks/{id}` - 更新任务
|
||||||
- `POST /api/task` - 创建任务
|
- `PATCH /api/tasks/{id}/complete` - 切换完成状态
|
||||||
- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位)
|
- `DELETE /api/tasks/{id}` - 删除任务
|
||||||
- `PATCH /api/task/{id}/toggle` - 切换完成状态
|
|
||||||
- `DELETE /api/task/{id}` - 删除任务
|
|
||||||
- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表
|
|
||||||
|
|
||||||
## 🤝 交流与贡献
|
## 🤝 交流与贡献
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
# Hua.Todo 技术设计文档 v1.1.0
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
本文档描述 Hua.Todo v1.1.0 的技术设计方案,包括项目文件目录结构、模块划分、技术选型和实现细节。
|
||||||
|
|
||||||
|
## 2. 技术栈
|
||||||
|
|
||||||
|
### 2.1 后端技术栈
|
||||||
|
- **开发语言**: C# 10
|
||||||
|
- **框架**: .NET 10
|
||||||
|
- **UI 框架**: MAUI (Multi-platform App UI) + Avalonia (Linux 支持)
|
||||||
|
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
|
||||||
|
- **API 框架**: ASP.NET Core Web API
|
||||||
|
- **数据访问**: Entity Framework Core
|
||||||
|
- **数据库**: SQLite (本地存储)
|
||||||
|
- **日志**: Serilog
|
||||||
|
- **依赖注入**: Microsoft.Extensions.DependencyInjection
|
||||||
|
|
||||||
|
### 2.2 前端技术栈
|
||||||
|
- **开发语言**: JavaScript/TypeScript
|
||||||
|
- **框架**: Vue.js 3
|
||||||
|
- **构建工具**: Vite
|
||||||
|
- **HTTP 客户端**: Axios
|
||||||
|
- **状态管理**: Pinia
|
||||||
|
- **UI 组件库**: Element Plus / Vant (移动端)
|
||||||
|
- **CSS 预处理器**: SCSS
|
||||||
|
|
||||||
|
## 3. 项目目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Hua.Todo/
|
||||||
|
├── docs/ # 文档目录
|
||||||
|
│ ├── manual/ # 用户/开发者手册
|
||||||
|
│ │ ├── 03-技术栈与模块.md
|
||||||
|
│ │ ├── 06-版本记录.md
|
||||||
|
│ │ ├── 01-技术设计文档.md(本文件)
|
||||||
|
│ │ ├── 04-代码规范文档.md
|
||||||
|
│ │ └── 05-部署文档.md
|
||||||
|
│ └── project/ # 项目进度/需求文档
|
||||||
|
│ ├── 产品需求文档.md
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── src/ # 源代码目录
|
||||||
|
│ ├── Hua.Todo.Maui/ # MAUI 主项目(跨平台入口)
|
||||||
|
│ │ ├── Platforms/ # 平台特定代码
|
||||||
|
│ │ │ ├── Windows/ # Windows 平台代码
|
||||||
|
│ │ │ │ ├── App.xaml # Windows 应用入口
|
||||||
|
│ │ │ │ └── Services/ # Windows 平台服务
|
||||||
|
│ │ │ │ └── HotKeyService.cs
|
||||||
|
│ │ │ ├── MacCatalyst/ # macOS 平台代码
|
||||||
|
│ │ │ │ ├── App.xaml
|
||||||
|
│ │ │ │ └── Services/
|
||||||
|
│ │ │ │ └── HotKeyService.cs
|
||||||
|
│ │ │ ├── Android/ # Android 平台代码
|
||||||
|
│ │ │ │ ├── MainActivity.cs
|
||||||
|
│ │ │ │ └── Services/
|
||||||
|
│ │ │ │ └── NotificationService.cs
|
||||||
|
│ │ │ └── iOS/ # iOS 平台代码
|
||||||
|
│ │ │ ├── AppDelegate.cs
|
||||||
|
│ │ │ └── Services/
|
||||||
|
│ │ │ └── NotificationService.cs
|
||||||
|
│ │ ├── Resources/ # 资源文件
|
||||||
|
│ │ │ ├── Images/ # 图片资源
|
||||||
|
│ │ │ ├── Styles/ # 样式资源
|
||||||
|
│ │ │ └── Fonts/ # 字体资源
|
||||||
|
│ │ ├── Controls/ # 自定义控件
|
||||||
|
│ │ │ └── WebViewContainer.xaml
|
||||||
|
│ │ ├── Services/ # 服务层
|
||||||
|
│ │ │ ├── IHotKeyService.cs
|
||||||
|
│ │ │ ├── IPlatformService.cs
|
||||||
|
│ │ │ └── AppLifecycleService.cs
|
||||||
|
│ │ ├── App.xaml # MAUI 应用入口
|
||||||
|
│ │ ├── App.xaml.cs
|
||||||
|
│ │ ├── MauiProgram.cs # MAUI 程序配置
|
||||||
|
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
|
||||||
|
│ │
|
||||||
|
│ ├── Hua.Todo.Avalonia/ # Avalonia 项目(Linux 支持)
|
||||||
|
│ │ ├── Assets/ # 资源文件
|
||||||
|
│ │ │ └── icon.ico # 应用图标
|
||||||
|
│ │ ├── Services/ # 服务层
|
||||||
|
│ │ │ ├── EmbeddedWebServerServiceFactory.cs
|
||||||
|
│ │ │ ├── GlobalHotKeyServiceFactory.cs
|
||||||
|
│ │ │ ├── NoopEmbeddedWebServerService.cs
|
||||||
|
│ │ │ └── Platforms/ # 平台特定服务
|
||||||
|
│ │ │ ├── AndroidGlobalHotKeyService.cs
|
||||||
|
│ │ │ └── LinuxGlobalHotKeyService.cs
|
||||||
|
│ │ ├── Views/ # 视图
|
||||||
|
│ │ │ ├── MainView.axaml
|
||||||
|
│ │ │ ├── MainView.axaml.cs
|
||||||
|
│ │ │ ├── MainWindow.axaml
|
||||||
|
│ │ │ └── MainWindow.axaml.cs
|
||||||
|
│ │ ├── App.axaml # Avalonia 应用入口
|
||||||
|
│ │ ├── App.axaml.cs
|
||||||
|
│ │ ├── Program.cs # Avalonia 程序配置
|
||||||
|
│ │ ├── appsettings.json # 配置文件
|
||||||
|
│ │ ├── setup.iss # 安装脚本
|
||||||
|
│ │ ├── wwwroot/ # 前端静态资源
|
||||||
|
│ │ └── Hua.Todo.Avalonia.csproj # Avalonia 项目文件
|
||||||
|
│ │
|
||||||
|
│ ├── Hua.Todo.Host/ # 后端 API 项目
|
||||||
|
│ │ ├── Controllers/ # API 控制器
|
||||||
|
│ │ │ ├── TasksController.cs
|
||||||
|
│ │ │ ├── SettingsController.cs
|
||||||
|
│ │ │ └── SyncController.cs
|
||||||
|
│ │ ├── Models/ # 数据模型
|
||||||
|
│ │ │ ├── Task.cs
|
||||||
|
│ │ │ ├── TaskDto.cs
|
||||||
|
│ │ │ └── ApiResponse.cs
|
||||||
|
│ │ ├── Services/ # 业务服务
|
||||||
|
│ │ │ ├── ITaskService.cs
|
||||||
|
│ │ │ ├── TaskService.cs
|
||||||
|
│ │ │ ├── ISyncService.cs
|
||||||
|
│ │ │ └── SyncService.cs
|
||||||
|
│ │ ├── Data/ # 数据访问层
|
||||||
|
│ │ │ ├── TodoDbContext.cs
|
||||||
|
│ │ │ ├── Converters/ # 类型转换器
|
||||||
|
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
|
||||||
|
│ │ │ ├── Repositories/
|
||||||
|
│ │ │ │ ├── ITaskRepository.cs
|
||||||
|
│ │ │ │ └── TaskRepository.cs
|
||||||
|
│ │ │ └── Migrations/ # 数据库迁移
|
||||||
|
│ │ ├── Middleware/ # 中间件
|
||||||
|
│ │ │ └── ExceptionMiddleware.cs
|
||||||
|
│ │ ├── Extensions/ # 扩展方法
|
||||||
|
│ │ │ └── ServiceCollectionExtensions.cs
|
||||||
|
│ │ ├── Program.cs # API 入口
|
||||||
|
│ │ ├── appsettings.json # 配置文件
|
||||||
|
│ │ └── Hua.Todo.Host.csproj # Host 项目文件
|
||||||
|
│ │
|
||||||
|
│ ├── Hua.Todo.Application/ # 应用层
|
||||||
|
│ │ ├── Data/ # 数据访问
|
||||||
|
│ │ │ ├── TodoDbContext.cs
|
||||||
|
│ │ │ └── Converters/ # 类型转换器
|
||||||
|
│ │ │ └── LenientUtcDateTimeStringConverter.cs
|
||||||
|
│ │ └── Hua.Todo.Application.csproj # Application 项目文件
|
||||||
|
│ │
|
||||||
|
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
|
||||||
|
│ │ ├── Entities/ # 实体类
|
||||||
|
│ │ │ ├── Task.cs
|
||||||
|
│ │ │ └── TaskPriority.cs
|
||||||
|
│ │ ├── Interfaces/ # 接口定义
|
||||||
|
│ │ │ ├── ITaskRepository.cs
|
||||||
|
│ │ │ └── IUnitOfWork.cs
|
||||||
|
│ │ ├── ValueObjects/ # 值对象
|
||||||
|
│ │ │ └── TaskTitle.cs
|
||||||
|
│ │ ├── Specifications/ # 规范模式
|
||||||
|
│ │ │ └── TaskSpecifications.cs
|
||||||
|
│ │ └── Hua.Todo.Core.csproj # Core 项目文件
|
||||||
|
│ │
|
||||||
|
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
|
||||||
|
│ │ ├── public/ # 静态资源
|
||||||
|
│ │ │ └── favicon.svg # 网站图标
|
||||||
|
│ │ ├── src/ # 源代码
|
||||||
|
│ │ │ ├── api/ # API 调用
|
||||||
|
│ │ │ │ ├── client.ts # HTTP 客户端配置
|
||||||
|
│ │ │ │ ├── tasks.ts # 任务相关 API
|
||||||
|
│ │ │ │ └── settings.ts # 设置相关 API
|
||||||
|
│ │ │ ├── assets/ # 资源文件
|
||||||
|
│ │ │ │ ├── images/
|
||||||
|
│ │ │ │ └── styles/
|
||||||
|
│ │ │ ├── components/ # Vue 组件
|
||||||
|
│ │ │ │ ├── TaskList.vue
|
||||||
|
│ │ │ │ ├── TaskItem.vue
|
||||||
|
│ │ │ │ ├── QuickEntry.vue
|
||||||
|
│ │ │ │ └── Settings.vue
|
||||||
|
│ │ │ ├── composables/ # 组合式函数
|
||||||
|
│ │ │ │ ├── useTasks.ts
|
||||||
|
│ │ │ │ └── useHotKey.ts
|
||||||
|
│ │ │ ├── stores/ # 状态管理 (Pinia)
|
||||||
|
│ │ │ │ ├── tasks.ts
|
||||||
|
│ │ │ │ └── settings.ts
|
||||||
|
│ │ │ ├── types/ # TypeScript 类型定义
|
||||||
|
│ │ │ │ ├── task.ts
|
||||||
|
│ │ │ │ └── api.ts
|
||||||
|
│ │ │ ├── utils/ # 工具函数
|
||||||
|
│ │ │ │ ├── date.ts
|
||||||
|
│ │ │ │ └── storage.ts
|
||||||
|
│ │ │ ├── App.vue # 根组件
|
||||||
|
│ │ │ └── main.ts # 应用入口
|
||||||
|
│ │ ├── package.json # 依赖配置
|
||||||
|
│ │ ├── vite.config.ts # Vite 配置
|
||||||
|
│ │ ├── tsconfig.json # TypeScript 配置
|
||||||
|
│ │ └── index.html # HTML 模板
|
||||||
|
│ │
|
||||||
|
│ └── Hua.Todo.Tests/ # 测试项目
|
||||||
|
│ ├── Unit/ # 单元测试
|
||||||
|
│ │ ├── Services/
|
||||||
|
│ │ │ └── TaskServiceTests.cs
|
||||||
|
│ │ └── Controllers/
|
||||||
|
│ │ └── TasksControllerTests.cs
|
||||||
|
│ ├── Integration/ # 集成测试
|
||||||
|
│ │ └── ApiIntegrationTests.cs
|
||||||
|
│ └── Hua.Todo.Tests.csproj
|
||||||
|
│
|
||||||
|
├── .gitignore # Git 忽略文件
|
||||||
|
├── Hua.Todo.sln # 解决方案文件
|
||||||
|
└── README.md # 项目说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 模块设计
|
||||||
|
|
||||||
|
### 4.1 MAUI 主项目 (Hua.Todo.Maui)
|
||||||
|
**职责**:
|
||||||
|
- 应用程序入口和生命周期管理
|
||||||
|
- 平台特定功能封装
|
||||||
|
- WebView 容器管理
|
||||||
|
- 本地 HTTP 服务器启动
|
||||||
|
|
||||||
|
**调试与接口文档**:
|
||||||
|
- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。
|
||||||
|
|
||||||
|
**关键组件**:
|
||||||
|
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
|
||||||
|
- `App.xaml.cs`: 应用程序主入口
|
||||||
|
- `WebViewContainer`: 封装 WebView 控件
|
||||||
|
- 平台特定服务: 快捷键、通知等
|
||||||
|
|
||||||
|
### 4.2 Avalonia 项目 (Hua.Todo.Avalonia)
|
||||||
|
**职责**:
|
||||||
|
- Linux 平台支持
|
||||||
|
- 桌面交互功能(托盘菜单、全局热键等)
|
||||||
|
- WebView 容器管理
|
||||||
|
- 本地 HTTP 服务器启动
|
||||||
|
|
||||||
|
**关键组件**:
|
||||||
|
- `Program.cs`: 配置 Avalonia 应用和依赖注入
|
||||||
|
- `App.axaml.cs`: 应用程序主入口
|
||||||
|
- `MainWindow.axaml.cs`: 主窗口管理
|
||||||
|
- `MainView.axaml.cs`: 主视图管理
|
||||||
|
- `GlobalHotKeyServiceFactory`: 全局热键服务工厂
|
||||||
|
- `EmbeddedWebServerServiceFactory`: 内嵌 Web 服务器服务工厂
|
||||||
|
- 平台特定服务: Linux 全局热键等
|
||||||
|
|
||||||
|
### 4.3 后端 API 项目 (Hua.Todo.Host)
|
||||||
|
**职责**:
|
||||||
|
- 提供 RESTful API 接口
|
||||||
|
- 业务逻辑处理
|
||||||
|
- 数据访问和持久化
|
||||||
|
- 本地 HTTP 服务器托管
|
||||||
|
|
||||||
|
**接口文档**:
|
||||||
|
- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。
|
||||||
|
|
||||||
|
**关键组件**:
|
||||||
|
- `CloudSync`: 云同步 Minimal APIs(`/auth`、`/tasks`、`/sync`、`/security`)
|
||||||
|
- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API(`/api/{service}/...`)对外暴露
|
||||||
|
- `Data`: 数据访问层和数据库上下文
|
||||||
|
- `Program.cs`: API 服务器配置和启动
|
||||||
|
|
||||||
|
### 4.4 核心业务层 (Hua.Todo.Core)
|
||||||
|
**职责**:
|
||||||
|
- 定义领域模型和业务规则
|
||||||
|
- 提供核心业务接口
|
||||||
|
- 实现领域驱动设计模式
|
||||||
|
|
||||||
|
**关键组件**:
|
||||||
|
- `Entities`: 领域实体
|
||||||
|
- `Interfaces`: 业务接口定义
|
||||||
|
- `ValueObjects`: 值对象
|
||||||
|
- `Specifications`: 业务规范
|
||||||
|
|
||||||
|
### 4.5 前端 Web 项目 (Hua.Todo.Web)
|
||||||
|
**职责**:
|
||||||
|
- 用户界面展示
|
||||||
|
- 用户交互处理
|
||||||
|
- HTTP API 调用
|
||||||
|
- 状态管理
|
||||||
|
|
||||||
|
**关键组件**:
|
||||||
|
- `components`: Vue 组件
|
||||||
|
- `api`: API 调用封装
|
||||||
|
- `stores`: 状态管理
|
||||||
|
- `composables`: 组合式函数
|
||||||
|
|
||||||
|
## 5. HTTP API 设计
|
||||||
|
|
||||||
|
### 5.1 API 基础配置
|
||||||
|
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`)
|
||||||
|
- **基础 URL(MAUI 内嵌模式)**: `{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 # 获取子任务列表
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 云同步 API(Host 模式)
|
||||||
|
```
|
||||||
|
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 端到端测试
|
||||||
|
- 跨平台功能测试
|
||||||
|
- 用户流程测试
|
||||||
|
- 性能测试
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
# Todo 待办项云同步规则
|
||||||
|
|
||||||
|
> 本文档汇总 Hua.Todo 项目的 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 Token(GUID 格式 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-Wins(LWW)合并**:同一字段的多设备并发修改,以 `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.ts(Axios 实例)
|
||||||
|
|
||||||
|
- 与通用 `client.ts`(baseURL 含 `/api`)解耦,云同步专用。
|
||||||
|
- **请求拦截器**:MAUI 模式下从 `CloudSyncStorage` 读取 `serverUrl`,Host 模式不设 baseURL 走同源;自动附加 `Bearer {accessToken}`。
|
||||||
|
- **响应拦截器**:
|
||||||
|
- 401 → 清除本地会话,弹出重新登录提示。
|
||||||
|
- 403 `FORBIDDEN` → Toast 提示权限不足。
|
||||||
|
- 其他错误 → 统一 Toast 通知。
|
||||||
|
|
||||||
|
### 8.2 cloudSync.ts(API 封装)
|
||||||
|
|
||||||
|
提供 `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.vue(UI 组件)
|
||||||
|
|
||||||
|
功能区块:
|
||||||
|
|
||||||
|
- **同步开关**: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)
|
||||||
|
> - 技术设计文档:[技术设计文档.md](./01-技术设计文档.md)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 技术栈与模块说明
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
### 后端技术栈
|
||||||
|
- **开发语言**:C# 13 (基于 .NET 10)
|
||||||
|
- **框架**:.NET 10 (Preview/Early Adopter)
|
||||||
|
- **UI 框架**:MAUI(移动端/部分桌面) + Avalonia(桌面端)
|
||||||
|
- **Web 服务器**:Kestrel (ASP.NET Core 内置)
|
||||||
|
- **API 框架**:ASP.NET Core Web API (支持动态 API 生成)
|
||||||
|
- **数据访问**:Entity Framework Core 10.0
|
||||||
|
- **数据库**:SQLite (本地存储)
|
||||||
|
- **依赖注入**:Microsoft.Extensions.DependencyInjection
|
||||||
|
|
||||||
|
### 前端技术栈
|
||||||
|
- **开发语言**:TypeScript 5+
|
||||||
|
- **框架**:Vue.js 3
|
||||||
|
- **构建工具**:Vite 5+
|
||||||
|
- **HTTP 客户端**:Axios
|
||||||
|
- **状态管理**:Pinia
|
||||||
|
- **UI 组件库**:Element Plus / Vant (移动端)
|
||||||
|
- **CSS 预处理器**:SCSS
|
||||||
|
|
||||||
|
## 🎯 核心模块说明
|
||||||
|
|
||||||
|
### Hua.Todo.Core
|
||||||
|
领域实体层,定义核心实体(TaskEntity)、安全策略实体(SecurityPolicyEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
|
||||||
|
|
||||||
|
### Hua.Todo.Application
|
||||||
|
应用层实现,包含:
|
||||||
|
- **业务逻辑**:TaskService 实现。
|
||||||
|
- **动态 API**:基于 Middleware 的动态 API 生成逻辑。
|
||||||
|
- **云同步 (CloudSync)**:包含 Auth 验证、同步服务、安全策略管理等。
|
||||||
|
- **数据访问**:EF Core 数据库上下文(TodoDbContext)与迁移。
|
||||||
|
|
||||||
|
### Hua.Todo.Host
|
||||||
|
后端 API 宿主,作为独立 Server 运行时提供运行环境和配置。
|
||||||
|
|
||||||
|
### Hua.Todo.Web
|
||||||
|
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
|
||||||
|
|
||||||
|
### Hua.Todo.Maui
|
||||||
|
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
|
||||||
|
|
||||||
|
### Hua.Todo.Avalonia
|
||||||
|
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
# Hua.Todo 代码规范文档 v1.2.8
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
|
||||||
|
|
||||||
|
## 2. 通用规范
|
||||||
|
|
||||||
|
### 2.1 命名约定
|
||||||
|
- **使用有意义的名称**: 变量、函数、类名应清晰表达其用途
|
||||||
|
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
|
||||||
|
- **一致性**: 在整个项目中保持命名风格一致
|
||||||
|
|
||||||
|
### 2.2 注释规范 (强制)
|
||||||
|
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
|
||||||
|
- ** summary**:一句话说明用途
|
||||||
|
- ** param / returns**:关键参数/返回值说明
|
||||||
|
- ** 异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
|
||||||
|
- **复杂逻辑添加行内注释**
|
||||||
|
- **禁止在日志或注释中输出密钥、Token、用户隐私信息**
|
||||||
|
|
||||||
|
### 2.3 代码格式化
|
||||||
|
- **使用统一的代码格式化工具** (VS / IDE 默认格式化)
|
||||||
|
- **保持一致的缩进和空格**
|
||||||
|
- **每行代码不超过 120 字符**
|
||||||
|
- **文件末尾保留一个空行**
|
||||||
|
|
||||||
|
## 3. C# 代码规范
|
||||||
|
|
||||||
|
### 3.1 跨平台逻辑规范
|
||||||
|
- **禁止混写 `#if`**:禁止在同一文件内混写多个平台的大段 `#if` 实现。
|
||||||
|
- **优先使用 partial/接口**:应优先使用 `partial` 类、接口与平台目录分离。
|
||||||
|
- **说明平台差异**:平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
|
||||||
|
|
||||||
|
### 3.2 异步/后台任务
|
||||||
|
- **必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入**。
|
||||||
|
|
||||||
|
#### 类和接口
|
||||||
|
```csharp
|
||||||
|
// 类名使用 PascalCase
|
||||||
|
public class TaskService
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口名使用 PascalCase,以 I 开头
|
||||||
|
public interface ITaskService
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方法和属性
|
||||||
|
```csharp
|
||||||
|
// 方法名使用 PascalCase
|
||||||
|
public Task<List<Task>> GetTasksAsync()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// 属性名使用 PascalCase
|
||||||
|
public string Title { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 变量和参数
|
||||||
|
```csharp
|
||||||
|
// 私有字段使用 _camelCase
|
||||||
|
private readonly ITaskRepository _taskRepository;
|
||||||
|
|
||||||
|
// 局部变量使用 camelCase
|
||||||
|
var taskList = await GetTasksAsync();
|
||||||
|
|
||||||
|
// 方法参数使用 camelCase
|
||||||
|
public void CreateTask(string title, TaskPriority priority)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 常量
|
||||||
|
```csharp
|
||||||
|
// 常量使用 PascalCase
|
||||||
|
public const int MaxTaskTitleLength = 200;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 代码组织
|
||||||
|
|
||||||
|
#### 文件结构
|
||||||
|
```csharp
|
||||||
|
// 1. using 语句(按字母顺序)
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
// 2. 命名空间
|
||||||
|
namespace Hua.Todo.Api.Services;
|
||||||
|
|
||||||
|
// 3. XML 文档注释
|
||||||
|
/// <summary>
|
||||||
|
/// 任务服务实现
|
||||||
|
/// </summary>
|
||||||
|
public class TaskService : ITaskService
|
||||||
|
{
|
||||||
|
// 4. 私有字段
|
||||||
|
private readonly ITaskRepository _taskRepository;
|
||||||
|
|
||||||
|
// 5. 构造函数
|
||||||
|
public TaskService(ITaskRepository taskRepository)
|
||||||
|
{
|
||||||
|
_taskRepository = taskRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 公共方法
|
||||||
|
public async Task<List<Task>> GetTasksAsync()
|
||||||
|
{
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 私有方法
|
||||||
|
private bool ValidateTask(Task task)
|
||||||
|
{
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 命名空间组织
|
||||||
|
- 每个文件只包含一个命名空间
|
||||||
|
- 命名空间结构应与目录结构一致
|
||||||
|
- 使用 `.` 分隔层级
|
||||||
|
|
||||||
|
### 3.3 编码规范
|
||||||
|
|
||||||
|
#### 异步编程
|
||||||
|
```csharp
|
||||||
|
// 异步方法应以 Async 结尾
|
||||||
|
public async Task<Task> GetTaskByIdAsync(int id)
|
||||||
|
{
|
||||||
|
return await _taskRepository.GetByIdAsync(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 await 而非 .Result 或 .Wait
|
||||||
|
var task = await GetTaskByIdAsync(id);
|
||||||
|
|
||||||
|
// 使用 ConfigureAwait(false) 在库代码中
|
||||||
|
public async Task<List<Task>> GetTasksAsync()
|
||||||
|
{
|
||||||
|
return await _taskRepository.GetAllAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 依赖注入
|
||||||
|
```csharp
|
||||||
|
// 优先使用构造函数注入
|
||||||
|
public class TaskService : ITaskService
|
||||||
|
{
|
||||||
|
private readonly ITaskRepository _taskRepository;
|
||||||
|
private readonly ILogger<TaskService> _logger;
|
||||||
|
|
||||||
|
public TaskService(ITaskRepository taskRepository, ILogger<TaskService> logger)
|
||||||
|
{
|
||||||
|
_taskRepository = taskRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 异常处理
|
||||||
|
```csharp
|
||||||
|
// 使用具体的异常类型
|
||||||
|
public async Task<Task> GetTaskByIdAsync(int id)
|
||||||
|
{
|
||||||
|
var task = await _taskRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
if (task == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException($"Task with id {id} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 using 语句管理资源
|
||||||
|
using var context = new TodoDbContext();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LINQ 使用
|
||||||
|
```csharp
|
||||||
|
// 优先使用方法语法
|
||||||
|
var completedTasks = tasks.Where(t => t.IsCompleted).ToList();
|
||||||
|
|
||||||
|
// 复杂查询使用查询语法
|
||||||
|
var query = from task in tasks
|
||||||
|
where task.IsCompleted
|
||||||
|
orderby task.CreatedAt descending
|
||||||
|
select task;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 文档注释
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定 ID 的任务
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">任务 ID</param>
|
||||||
|
/// <returns>任务对象</returns>
|
||||||
|
/// <exception cref="NotFoundException">当任务不存在时抛出</exception>
|
||||||
|
public async Task<Task> GetTaskByIdAsync(int id)
|
||||||
|
{
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. JavaScript/TypeScript 代码规范
|
||||||
|
|
||||||
|
### 4.1 命名规范
|
||||||
|
|
||||||
|
#### 变量和函数
|
||||||
|
```typescript
|
||||||
|
// 变量使用 camelCase
|
||||||
|
const taskList = [];
|
||||||
|
let currentTask = null;
|
||||||
|
|
||||||
|
// 函数使用 camelCase
|
||||||
|
function getTasks() {
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常量使用 UPPER_SNAKE_CASE
|
||||||
|
const MAX_TASK_TITLE_LENGTH = 200;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 类和接口
|
||||||
|
```typescript
|
||||||
|
// 类名使用 PascalCase
|
||||||
|
class TaskService {
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
|
||||||
|
// 接口名使用 PascalCase
|
||||||
|
interface Task {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型别名使用 PascalCase
|
||||||
|
type TaskPriority = 'high' | 'medium' | 'low';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 代码组织
|
||||||
|
|
||||||
|
#### 文件结构
|
||||||
|
```typescript
|
||||||
|
// 1. 导入语句
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useTaskStore } from '@/stores/tasks';
|
||||||
|
import type { Task } from '@/types/task';
|
||||||
|
|
||||||
|
// 2. 类型定义
|
||||||
|
interface TaskForm {
|
||||||
|
title: string;
|
||||||
|
priority: TaskPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 常量定义
|
||||||
|
const DEFAULT_PRIORITY: TaskPriority = 'medium';
|
||||||
|
|
||||||
|
// 4. 组合式函数或组件
|
||||||
|
export function useTasks() {
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 模块导入
|
||||||
|
```typescript
|
||||||
|
// 优先使用 ES6 模块语法
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// 导出使用具名导出
|
||||||
|
export function useTasks() {
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTasks;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 TypeScript 规范
|
||||||
|
|
||||||
|
#### 类型定义
|
||||||
|
```typescript
|
||||||
|
// 为所有函数参数和返回值添加类型
|
||||||
|
function getTaskById(id: number): Task | null {
|
||||||
|
// 实现
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用接口定义对象类型
|
||||||
|
interface Task {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
priority: TaskPriority;
|
||||||
|
isCompleted: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用类型别名定义联合类型
|
||||||
|
type TaskPriority = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
// 使用泛型提高代码复用性
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 类型断言
|
||||||
|
```typescript
|
||||||
|
// 优先使用类型守卫而非类型断言
|
||||||
|
function isTask(obj: unknown): obj is Task {
|
||||||
|
return typeof obj === 'object' && obj !== null && 'id' in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免使用 as any
|
||||||
|
const task = response.data as Task; // 避免
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 异步编程
|
||||||
|
```typescript
|
||||||
|
// 使用 async/await 而非 Promise 链
|
||||||
|
async function getTasks(): Promise<Task[]> {
|
||||||
|
const response = await axios.get('/api/task');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
try {
|
||||||
|
const tasks = await getTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tasks:', error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Vue.js 代码规范
|
||||||
|
|
||||||
|
### 5.1 组件命名
|
||||||
|
```vue
|
||||||
|
<!-- 组件名使用 PascalCase -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 组件名应与文件名一致
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 模板中使用 kebab-case -->
|
||||||
|
<task-item :task="task" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 组件结构
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 1. 模板 -->
|
||||||
|
<div class="task-list">
|
||||||
|
<task-item
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
:task="task"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 2. 导入
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useTaskStore } from '@/stores/tasks';
|
||||||
|
import TaskItem from './TaskItem.vue';
|
||||||
|
|
||||||
|
// 3. Props 定义
|
||||||
|
interface Props {
|
||||||
|
filter: 'all' | 'active' | 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
filter: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Emits 定义
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'task-created', task: Task): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 5. 响应式状态
|
||||||
|
const taskStore = useTaskStore();
|
||||||
|
const tasks = computed(() => taskStore.filteredTasks(props.filter));
|
||||||
|
|
||||||
|
// 6. 方法
|
||||||
|
const handleCreateTask = async (title: string) => {
|
||||||
|
const task = await taskStore.createTask(title);
|
||||||
|
emit('task-created', task);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 6. 样式 */
|
||||||
|
.task-list {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 组合式函数规范
|
||||||
|
```typescript
|
||||||
|
// composables/useTasks.ts
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useTaskStore } from '@/stores/tasks';
|
||||||
|
|
||||||
|
export function useTasks() {
|
||||||
|
const taskStore = useTaskStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const tasks = computed(() => taskStore.tasks);
|
||||||
|
const completedTasks = computed(() => taskStore.completedTasks);
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await taskStore.fetchTasks();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Failed to fetch tasks';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
completedTasks,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchTasks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 状态管理规范
|
||||||
|
```typescript
|
||||||
|
// stores/tasks.ts
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { Task } from '@/types/task';
|
||||||
|
|
||||||
|
export const useTaskStore = defineStore('tasks', () => {
|
||||||
|
// State
|
||||||
|
const tasks = ref<Task[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const activeTasks = computed(() =>
|
||||||
|
tasks.value.filter(task => !task.isCompleted)
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedTasks = computed(() =>
|
||||||
|
tasks.value.filter(task => task.isCompleted)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function fetchTasks() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/task');
|
||||||
|
tasks.value = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Failed to fetch tasks';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activeTasks,
|
||||||
|
completedTasks,
|
||||||
|
fetchTasks
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. API 设计规范
|
||||||
|
|
||||||
|
### 6.1 RESTful API 设计
|
||||||
|
```csharp
|
||||||
|
// 本项目的任务端点采用 Dynamic API 路由约定:/api/task(由中间件反射分发)
|
||||||
|
[HttpGet("task")]
|
||||||
|
public async Task<ActionResult<List<Task>>> GetTasks()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用资源 ID
|
||||||
|
[HttpGet("task/{id}")]
|
||||||
|
public async Task<ActionResult<Task>> GetTask(int id)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 HTTP 方法表示操作
|
||||||
|
[HttpPost("task")]
|
||||||
|
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("task")]
|
||||||
|
public async Task<ActionResult<Task>> UpdateTask(UpdateTaskDto dto)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("task/{id}")]
|
||||||
|
public async Task<ActionResult> DeleteTask(int id)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 响应格式
|
||||||
|
```csharp
|
||||||
|
// 统一的响应格式
|
||||||
|
public class ApiResponse<T>
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public T Data { get; set; }
|
||||||
|
public string Message { get; set; }
|
||||||
|
public List<string> Errors { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功响应
|
||||||
|
return Ok(new ApiResponse<Task>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = task,
|
||||||
|
Message = "Task created successfully"
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误响应
|
||||||
|
return BadRequest(new ApiResponse<object>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "Validation failed",
|
||||||
|
Errors = new List<string> { "Title is required" }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Git 提交规范
|
||||||
|
|
||||||
|
### 7.1 提交信息格式
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Type 类型
|
||||||
|
- `feat`: 新功能
|
||||||
|
- `fix`: 修复 bug
|
||||||
|
- `docs`: 文档更新
|
||||||
|
- `style`: 代码格式调整(不影响代码运行)
|
||||||
|
- `refactor`: 重构(既不是新功能也不是修复 bug)
|
||||||
|
- `perf`: 性能优化
|
||||||
|
- `test`: 测试相关
|
||||||
|
- `chore`: 构建过程或辅助工具的变动
|
||||||
|
|
||||||
|
### 7.3 示例
|
||||||
|
```
|
||||||
|
feat(api): add task completion endpoint
|
||||||
|
|
||||||
|
- Add PATCH /api/task/{id}/toggle endpoint
|
||||||
|
- Update task service to handle completion logic
|
||||||
|
- Add unit tests for completion functionality
|
||||||
|
|
||||||
|
Closes #123
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 测试规范
|
||||||
|
|
||||||
|
### 8.1 单元测试
|
||||||
|
```csharp
|
||||||
|
// 测试类命名: ClassName + Tests
|
||||||
|
public class TaskServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTasksAsync_ReturnsAllTasks()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockRepository = new Mock<ITaskRepository>();
|
||||||
|
var service = new TaskService(mockRepository.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetTasksAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(3, result.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 集成测试
|
||||||
|
```csharp
|
||||||
|
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public ApiIntegrationTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTasks_ReturnsSuccessAndCorrectContentType()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync("/api/task");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 代码审查清单
|
||||||
|
|
||||||
|
### 9.1 代码质量
|
||||||
|
- [ ] 代码符合项目规范
|
||||||
|
- [ ] 变量和函数命名清晰
|
||||||
|
- [ ] 没有重复代码
|
||||||
|
- [ ] 复杂逻辑有注释说明
|
||||||
|
- [ ] 没有硬编码的魔法数字
|
||||||
|
|
||||||
|
### 9.2 功能正确性
|
||||||
|
- [ ] 功能实现符合需求
|
||||||
|
- [ ] 边界条件已处理
|
||||||
|
- [ ] 错误处理完善
|
||||||
|
- [ ] 有相应的单元测试
|
||||||
|
|
||||||
|
### 9.3 性能和安全
|
||||||
|
- [ ] 没有性能问题
|
||||||
|
- [ ] 敏感数据已保护
|
||||||
|
- [ ] 输入验证完善
|
||||||
|
- [ ] 没有安全漏洞
|
||||||
|
|
||||||
|
## 10. 工具配置
|
||||||
|
|
||||||
|
### 10.1 C# 工具
|
||||||
|
- **代码格式化**: dotnet format
|
||||||
|
- **代码分析**: Roslyn Analyzers
|
||||||
|
- **代码风格**: .editorconfig
|
||||||
|
- **文档生成**: DocFX
|
||||||
|
|
||||||
|
### 10.2 JavaScript/TypeScript 工具
|
||||||
|
- **代码格式化**: Prettier
|
||||||
|
- **代码检查**: ESLint
|
||||||
|
- **类型检查**: TypeScript
|
||||||
|
- **代码风格**: .prettierrc
|
||||||
|
|
||||||
|
### 10.3 .editorconfig 示例
|
||||||
|
```ini
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = crlf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{js,ts,vue}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
```
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Hua.Todo 构建与部署手册
|
||||||
|
|
||||||
|
本文档提供 Hua.Todo 项目的版本构建指南与多平台部署流程,侧重于如何产出可分发的版本并将其安装或部署到目标机器。
|
||||||
|
|
||||||
|
## 1. 🏗️ 版本构建流程 (Release Build)
|
||||||
|
|
||||||
|
在进行任何部署之前,必须先通过自动化脚本构建出 Release 版本的产物。
|
||||||
|
|
||||||
|
### 1.1 前提要求
|
||||||
|
|
||||||
|
- **环境检查**:已安装 .NET 10 SDK、Node.js 18+ 以及 Inno Setup 6(仅 Windows 打包需要)。
|
||||||
|
- **脚本入口**:位于根目录的 `publish.ps1` 系列脚本。
|
||||||
|
|
||||||
|
### 1.2 执行构建
|
||||||
|
|
||||||
|
根据目标平台执行对应的构建命令:
|
||||||
|
|
||||||
|
- **全平台一键构建**:
|
||||||
|
```powershell
|
||||||
|
.\publish.ps1 -Windows -Linux
|
||||||
|
```
|
||||||
|
- **Windows 版本构建**:
|
||||||
|
```powershell
|
||||||
|
.\publish-windows.ps1
|
||||||
|
```
|
||||||
|
- **Linux 版本构建**:
|
||||||
|
```powershell
|
||||||
|
.\publish-linux.ps1 -RuntimeIdentifier linux-x64 -SelfContained
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 产物输出位置
|
||||||
|
|
||||||
|
所有构建产物将按平台归类在根目录的 `artifacts/` 文件夹下:
|
||||||
|
|
||||||
|
- **Windows**: `artifacts\windows\win-x64\installer\` (安装包) 与 `publish\` (解压即用版)
|
||||||
|
- **Linux**: `artifacts\linux\linux-x64\` (`.tar.gz` 压缩包)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 2. 🪟 Windows 客户端部署
|
||||||
|
|
||||||
|
### 2.1 安装包分发 (Recommended)
|
||||||
|
|
||||||
|
1. **分发文件**:将 `hua.todo-{version}-win-x64-setup.exe` 提供给终端用户。
|
||||||
|
2. **安装流程**:用户运行安装程序,程序将自动:
|
||||||
|
- 安装到 `%ProgramFiles%\Hua.Todo`(或用户自定义路径)。
|
||||||
|
- 创建桌面与开始菜单快捷方式。
|
||||||
|
- 写入注册表以支持卸载。
|
||||||
|
3. **静默安装**:支持 Inno Setup 标准静默参数 `/VERYSILENT /SUPPRESSMSGBOXES`。
|
||||||
|
|
||||||
|
### 2.2 绿色版部署
|
||||||
|
|
||||||
|
1. **分发文件**:将 `artifacts\windows\win-x64\publish\` 文件夹整体打包。
|
||||||
|
2. **运行要求**:目标机器需安装 **WebView2 Runtime**。
|
||||||
|
3. **启动程序**:运行 `Hua.Todo.Maui.exe`。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 3. 🐧 Linux 客户端部署
|
||||||
|
|
||||||
|
目前提供基于 Avalonia 的 Linux 交付版本,采用 `.tar.gz` 压缩包形式。
|
||||||
|
|
||||||
|
### 3.1 解压部署
|
||||||
|
|
||||||
|
1. **解压**:
|
||||||
|
```bash
|
||||||
|
tar -xzf hua.todo-{version}-linux-x64.tar.gz -C /opt/hua-todo
|
||||||
|
```
|
||||||
|
2. **运行环境**:
|
||||||
|
- 确保系统安装了 `libwebkit2gtk-4.0-37`。
|
||||||
|
- 如果构建时未开启 `-SelfContained`,则需安装 .NET 10 Runtime。
|
||||||
|
3. **权限设置**:
|
||||||
|
```bash
|
||||||
|
chmod +x /opt/hua-todo/Hua.Todo.Avalonia
|
||||||
|
```
|
||||||
|
4. **启动**:直接运行 `/opt/hua-todo/Hua.Todo.Avalonia`。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 4. ☁️ 云同步服务端部署 (Server)
|
||||||
|
|
||||||
|
`Hua.Todo.Host` 可作为中心服务端部署,用于任务的云端同步。
|
||||||
|
|
||||||
|
### 4.1 Docker 部署 (Recommended)
|
||||||
|
|
||||||
|
1. **上传代码**或将 `src/Hua.Todo.Host/Dockerfile` 复制到服务器。
|
||||||
|
2. **构建与启动**:
|
||||||
|
```bash
|
||||||
|
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
|
||||||
|
docker run -d \
|
||||||
|
--name hua-todo-server \
|
||||||
|
-p 5173:5173 \
|
||||||
|
-v /data/hua-todo/db:/app/data \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
hua-todo-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 直接部署 (Binary)
|
||||||
|
|
||||||
|
1. **构建 Host**:`dotnet publish src/Hua.Todo.Host -c Release -o ./dist`。
|
||||||
|
2. **同步产物**:将 `./dist` 内容同步到服务器。
|
||||||
|
3. **配置 Systemd 服务**(可选):
|
||||||
|
创建一个 `hua-todo.service` 文件,指向可执行程序并设置工作目录。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 5. ⚙️ 关键配置校准
|
||||||
|
|
||||||
|
### 5.1 数据持久化
|
||||||
|
|
||||||
|
- **SQLite 路径**:客户端数据默认存储在用户目录下的 `Hua.Todo/todo.db`;服务端数据存储在容器挂载卷或指定 `appsettings.json` 的连接字符串中。
|
||||||
|
- **备份建议**:定期备份 `todo.db` 文件。
|
||||||
|
|
||||||
|
### 5.2 端口与访问
|
||||||
|
|
||||||
|
- **客户端本地端口**:默认 `5057`。如果被占用,可在 `appsettings.json` 中修改 `WebServer.HostUrl`。
|
||||||
|
- **服务端访问**:确保服务端防火墙已开放对应的 API 端口(默认 5173)。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 6. 🛠️ 常见问题排查
|
||||||
|
|
||||||
|
- **客户端无法加载 UI**:检查目标机器是否安装了 WebView2 Runtime(Windows)或 WebKitGTK(Linux)。
|
||||||
|
- **同步连接失败**:
|
||||||
|
1. 确认服务端 API 是否可达(访问 `/swagger` 验证)。
|
||||||
|
2. 确认客户端配置的服务端地址格式(需包含 `http://` 或 `https://`)。
|
||||||
|
- **权限问题**:在 Linux 下运行前,务必执行 `chmod +x` 给主程序执行权限。
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 版本更新历史
|
||||||
|
|
||||||
|
## 🔄 版本更新
|
||||||
|
|
||||||
|
### 版本策略
|
||||||
|
|
||||||
|
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
|
||||||
|
- v1.0.0:初始 WPF 版本
|
||||||
|
- v1.1.0:MAUI + WebView 跨平台版本
|
||||||
|
- v1.2.0 (规划中):Linux 支持与增强功能
|
||||||
|
|
||||||
|
### v1.2.8 (2026-06-14)
|
||||||
|
|
||||||
|
- **文档**:新增 [任务同步规则.md](./02-任务同步规则.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.0(2026-04-07)
|
||||||
|
|
||||||
|
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
|
||||||
|
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
|
||||||
|
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
|
||||||
|
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
|
||||||
|
- **MAUI(Windows)内嵌 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 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `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` 代理配置。
|
||||||
|
- **文档与部署指南**:新增 `docs/manual/05-部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。
|
||||||
|
- **用户文档完善**:在规划中新增了 `docs/manual/08-新手指南.md` 和 `docs/manual/09-用户指南.md`。
|
||||||
|
27→
|
||||||
|
28→### 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` 发布脚本与 Flatpak(manifest/desktop entry/AppStream)基础结构。
|
||||||
|
- **关键词检索**:支持按任务标题关键词搜索。
|
||||||
|
- **标签系统**:引入多标签支持,提升任务组织效率。
|
||||||
|
- **暗色模式**:全平台适配暗色/深色主题。
|
||||||
|
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 其他信息
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
1. Fork 项目
|
||||||
|
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
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
|
||||||
|
- QQ 交流群:2167048911 (Hua.Todo 交流群)
|
||||||
+11
-11
@@ -314,7 +314,7 @@ const task = response.data as Task; // 避免
|
|||||||
```typescript
|
```typescript
|
||||||
// 使用 async/await 而非 Promise 链
|
// 使用 async/await 而非 Promise 链
|
||||||
async function getTasks(): Promise<Task[]> {
|
async function getTasks(): Promise<Task[]> {
|
||||||
const response = await axios.get('/api/task');
|
const response = await axios.get('/api/tasks');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +457,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
async function fetchTasks() {
|
async function fetchTasks() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/task');
|
const response = await fetch('/api/tasks');
|
||||||
tasks.value = await response.json();
|
tasks.value = await response.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = 'Failed to fetch tasks';
|
error.value = 'Failed to fetch tasks';
|
||||||
@@ -481,30 +481,30 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
|
|
||||||
### 6.1 RESTful API 设计
|
### 6.1 RESTful API 设计
|
||||||
```csharp
|
```csharp
|
||||||
// 本项目的任务端点采用 Dynamic API 路由约定:/api/task(由中间件反射分发)
|
// 使用名词复数形式
|
||||||
[HttpGet("task")]
|
[HttpGet("tasks")]
|
||||||
public async Task<ActionResult<List<Task>>> GetTasks()
|
public async Task<ActionResult<List<Task>>> GetTasks()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用资源 ID
|
// 使用资源 ID
|
||||||
[HttpGet("task/{id}")]
|
[HttpGet("tasks/{id}")]
|
||||||
public async Task<ActionResult<Task>> GetTask(int id)
|
public async Task<ActionResult<Task>> GetTask(int id)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 HTTP 方法表示操作
|
// 使用 HTTP 方法表示操作
|
||||||
[HttpPost("task")]
|
[HttpPost("tasks")]
|
||||||
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
|
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("task")]
|
[HttpPut("tasks/{id}")]
|
||||||
public async Task<ActionResult<Task>> UpdateTask(UpdateTaskDto dto)
|
public async Task<ActionResult<Task>> UpdateTask(int id, UpdateTaskDto dto)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("task/{id}")]
|
[HttpDelete("tasks/{id}")]
|
||||||
public async Task<ActionResult> DeleteTask(int id)
|
public async Task<ActionResult> DeleteTask(int id)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -563,7 +563,7 @@ return BadRequest(new ApiResponse<object>
|
|||||||
```
|
```
|
||||||
feat(api): add task completion endpoint
|
feat(api): add task completion endpoint
|
||||||
|
|
||||||
- Add PATCH /api/task/{id}/toggle endpoint
|
- Add PATCH /api/tasks/{id}/complete endpoint
|
||||||
- Update task service to handle completion logic
|
- Update task service to handle completion logic
|
||||||
- Add unit tests for completion functionality
|
- Add unit tests for completion functionality
|
||||||
|
|
||||||
@@ -609,7 +609,7 @@ public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
|||||||
public async Task GetTasks_ReturnsSuccessAndCorrectContentType()
|
public async Task GetTasks_ReturnsSuccessAndCorrectContentType()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync("/api/task");
|
var response = await _client.GetAsync("/api/tasks");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
### 后端技术栈
|
### 后端技术栈
|
||||||
- **开发语言**:C# 10
|
- **开发语言**:C# 10
|
||||||
- **框架**:.NET 10
|
- **框架**:.NET 10
|
||||||
- **UI 框架**:MAUI(移动端/部分桌面) + Avalonia(桌面端)
|
- **UI 框架**:MAUI (Multi-platform App UI)
|
||||||
- **Web 服务器**:Kestrel (ASP.NET Core 内置)
|
- **Web 服务器**:Kestrel (ASP.NET Core 内置)
|
||||||
- **API 框架**:ASP.NET Core Web API
|
- **API 框架**:ASP.NET Core Web API
|
||||||
- **数据访问**:Entity Framework Core
|
- **数据访问**:Entity Framework Core
|
||||||
@@ -37,6 +37,3 @@
|
|||||||
|
|
||||||
### Hua.Todo.Maui
|
### Hua.Todo.Maui
|
||||||
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
|
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
|
||||||
|
|
||||||
### Hua.Todo.Avalonia
|
|
||||||
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
|
|
||||||
|
|||||||
+20
-29
@@ -173,28 +173,22 @@ Hua.Todo/
|
|||||||
- WebView 容器管理
|
- WebView 容器管理
|
||||||
- 本地 HTTP 服务器启动
|
- 本地 HTTP 服务器启动
|
||||||
|
|
||||||
**调试与接口文档**:
|
|
||||||
- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。
|
|
||||||
|
|
||||||
**关键组件**:
|
**关键组件**:
|
||||||
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
|
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
|
||||||
- `App.xaml.cs`: 应用程序主入口
|
- `App.xaml.cs`: 应用程序主入口
|
||||||
- `WebViewContainer`: 封装 WebView 控件
|
- `WebViewContainer`: 封装 WebView 控件
|
||||||
- 平台特定服务: 快捷键、通知等
|
- 平台特定服务: 快捷键、通知等
|
||||||
|
|
||||||
### 4.2 后端 API 项目 (Hua.Todo.Host)
|
### 4.2 后端 API 项目 (Hua.Todo.Api)
|
||||||
**职责**:
|
**职责**:
|
||||||
- 提供 RESTful API 接口
|
- 提供 RESTful API 接口
|
||||||
- 业务逻辑处理
|
- 业务逻辑处理
|
||||||
- 数据访问和持久化
|
- 数据访问和持久化
|
||||||
- 本地 HTTP 服务器托管
|
- 本地 HTTP 服务器托管
|
||||||
|
|
||||||
**接口文档**:
|
|
||||||
- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。
|
|
||||||
|
|
||||||
**关键组件**:
|
**关键组件**:
|
||||||
- `CloudSync`: 云同步 Minimal APIs(`/auth`、`/tasks`、`/sync`、`/security`)
|
- `Controllers`: API 端点实现
|
||||||
- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API(`/api/{service}/...`)对外暴露
|
- `Services`: 业务逻辑服务
|
||||||
- `Data`: 数据访问层和数据库上下文
|
- `Data`: 数据访问层和数据库上下文
|
||||||
- `Program.cs`: API 服务器配置和启动
|
- `Program.cs`: API 服务器配置和启动
|
||||||
|
|
||||||
@@ -226,36 +220,33 @@ Hua.Todo/
|
|||||||
## 5. HTTP API 设计
|
## 5. HTTP API 设计
|
||||||
|
|
||||||
### 5.1 API 基础配置
|
### 5.1 API 基础配置
|
||||||
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`)
|
- **基础 URL**: `http://localhost:5000/api`
|
||||||
- **基础 URL(MAUI 内嵌模式)**: `{HostUrl}/api`(`HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`)
|
|
||||||
- **数据格式**: JSON
|
- **数据格式**: JSON
|
||||||
- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证)
|
- **认证方式**: 暂无(本地应用)
|
||||||
- **跨域配置**: 允许本地跨域请求
|
- **跨域配置**: 允许本地跨域请求
|
||||||
|
|
||||||
### 5.2 API 端点设计
|
### 5.2 API 端点设计
|
||||||
|
|
||||||
#### 任务管理 API
|
#### 任务管理 API
|
||||||
```
|
```
|
||||||
GET /api/task # 获取任务列表(默认:全部)
|
GET /api/tasks # 获取任务列表
|
||||||
GET /api/task/active # 获取未完成任务
|
GET /api/tasks/{id} # 获取单个任务
|
||||||
GET /api/task/completed # 获取已完成任务
|
POST /api/tasks # 创建任务
|
||||||
GET /api/task/{id} # 获取单个任务
|
PUT /api/tasks/{id} # 更新任务
|
||||||
POST /api/task # 创建任务
|
DELETE /api/tasks/{id} # 删除任务
|
||||||
PUT /api/task # 更新任务(通过 Body 内的 id 定位)
|
PATCH /api/tasks/{id}/complete # 标记任务完成
|
||||||
DELETE /api/task/{id} # 删除任务
|
|
||||||
PATCH /api/task/{id}/toggle # 切换完成状态
|
|
||||||
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 云同步 API(Host 模式)
|
#### 设置管理 API
|
||||||
```
|
```
|
||||||
POST /auth/bootstrap # 初始化管理员(仅首次)
|
GET /api/settings # 获取设置
|
||||||
POST /auth/login # 登录
|
PUT /api/settings # 更新设置
|
||||||
POST /auth/step-up # 二次验证(提升权限)
|
```
|
||||||
GET /tasks/ # 获取云端任务(只读)
|
|
||||||
POST /sync/ # 推送/拉取合并同步
|
#### 同步 API
|
||||||
GET /security/policy # 获取安全策略
|
```
|
||||||
PUT /security/policy # 更新安全策略
|
POST /api/sync/pull # 拉取远程数据
|
||||||
|
POST /api/sync/push # 推送本地数据
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. 数据库设计
|
## 6. 数据库设计
|
||||||
|
|||||||
+1
-16
@@ -9,21 +9,6 @@
|
|||||||
- v1.1.0:MAUI + WebView 跨平台版本
|
- v1.1.0:MAUI + WebView 跨平台版本
|
||||||
- v1.2.0 (规划中):Linux 支持与增强功能
|
- v1.2.0 (规划中):Linux 支持与增强功能
|
||||||
|
|
||||||
### v1.2.0(开发中,2026-04-07)
|
|
||||||
|
|
||||||
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
|
|
||||||
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
|
|
||||||
- **MAUI(Windows)内嵌 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 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `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)
|
### v1.1.1 (2026-04-06)
|
||||||
|
|
||||||
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
|
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
|
||||||
@@ -44,8 +29,8 @@
|
|||||||
### v1.2.0 规划内容 (即将推出)
|
### v1.2.0 规划内容 (即将推出)
|
||||||
|
|
||||||
- **Linux 官方支持**:正式适配 Linux 平台。
|
- **Linux 官方支持**:正式适配 Linux 平台。
|
||||||
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpak(manifest/desktop entry/AppStream)基础结构。
|
|
||||||
- **关键词检索**:支持按任务标题关键词搜索。
|
- **关键词检索**:支持按任务标题关键词搜索。
|
||||||
- **标签系统**:引入多标签支持,提升任务组织效率。
|
- **标签系统**:引入多标签支持,提升任务组织效率。
|
||||||
- **暗色模式**:全平台适配暗色/深色主题。
|
- **暗色模式**:全平台适配暗色/深色主题。
|
||||||
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
|
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
## 目标(来自 PRD)
|
||||||
|
|
||||||
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有“本地 API ↔ 前端”的交互协议
|
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有"本地 API ↔ 前端"的交互协议
|
||||||
- 任务检索(Search):主界面顶部新增搜索框,按任务标题模糊匹配
|
- Todo 待办项检索(Search):主界面顶部新增搜索框,按 Todo 待办项标题模糊匹配
|
||||||
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的“可控落盘”
|
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的"可控落盘"
|
||||||
|
|
||||||
## 当前实现基线(用于任务定位)
|
## 当前实现基线(用于工单定位)
|
||||||
|
|
||||||
- MAUI 启动与内嵌 WebServer:`src/Hua.Todo.Maui`
|
- MAUI 启动与内嵌 WebServer:`src/Hua.Todo.Maui`
|
||||||
- 前端(Vue)与 API client:`src/Hua.Todo.Web`
|
- 前端(Vue)与 API client:`src/Hua.Todo.Web`
|
||||||
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
|
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
|
||||||
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__`、`window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
|
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__`、`window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
|
||||||
|
|
||||||
## 任务流(并行建议)
|
## 工单流(并行建议)
|
||||||
|
|
||||||
- **Linux 入口线**:`01-*` + `02-*`
|
- **Linux 入口线**:`01-*` + `02-*`
|
||||||
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
|
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
|
||||||
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1` 与 `pack/linux/`)
|
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1` 与 `pack/linux/`)
|
||||||
- **Search 线**:`03-*`
|
- **Search 线**:`03-*`
|
||||||
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
|
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
|
||||||
- 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
|
- 03:已完成:主界面搜索框(按 Todo 待办项标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
|
||||||
- **云同步线**:`04-*` + `05-*` + `06-*`
|
- **云同步线**:`04-*` + `05-*` + `06-*`
|
||||||
- 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离)
|
- 04:服务端基础能力(登录、Todo 数据同步/读取、配置下发、用户隔离)
|
||||||
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务)
|
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取 Todo 数据)
|
||||||
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与“内存模式”)
|
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与"内存模式")
|
||||||
- **文档与验收线**:`07-*`
|
- **文档与验收线**:`07-*`
|
||||||
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
|
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
|
||||||
|
|
||||||
## 待验证表
|
## 待验证表
|
||||||
|
|
||||||
| 子任务 | 实现状态 | 验证状态 | 备注 |
|
| 子工单 | 实现状态 | 验证状态 | 备注 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
|
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
|
||||||
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
|
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
|
||||||
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 |
|
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状 Todo 待办项标题包含匹配 |
|
||||||
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
|
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
|
||||||
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增“云同步设置”弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 |
|
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址校验+保存探测、登录/登出、登录后拉取云端 Todo 数据并在主界面只读展示 |
|
||||||
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
|
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
|
||||||
| 07 - 文档与验收 | 未标注 | 待验证 | |
|
| 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)
|
## 交付判定(v1.2.0 Done Definition)
|
||||||
|
|
||||||
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
|
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
|
||||||
- Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰)
|
- Search:可在主界面按 Todo 待办项标题实时过滤(含层级 Todo 的展示策略清晰)
|
||||||
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发“是否允许落盘”;客户端在禁止落盘时不产生本地持久化
|
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户的 Todo 数据;服务端可下发"是否允许落盘";客户端在禁止落盘时不产生本地持久化
|
||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 新增一个 **Linux 桌面端入口**(Avalonia)作为 Hua.Todo 的 Linux 宿主
|
- 新增一个 **Linux 桌面端入口**(Avalonia)作为 Hua.Todo 的 Linux 宿主
|
||||||
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
|
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
|
||||||
- 复用既有“前端 ↔ 本地 API”的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
|
- 复用既有"前端 ↔ 本地 API"的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
|
||||||
|
|
||||||
## 范围
|
## 范围
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
|
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本工单,可并行
|
||||||
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`)
|
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`)
|
||||||
|
|
||||||
## 关键决策点(必须在实现前落定)
|
## 关键决策点(必须在实现前落定)
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
选型必须满足的最低能力:
|
选型必须满足的最低能力:
|
||||||
- 基础导航与本地资源加载
|
- 基础导航与本地资源加载
|
||||||
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
|
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
|
||||||
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证“HTTP API”路径通)
|
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证"HTTP API"路径通)
|
||||||
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
|
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
|
||||||
|
|
||||||
### 2) 资源加载策略(与 MAUI 对齐)
|
### 2) 资源加载策略(与 MAUI 对齐)
|
||||||
@@ -75,12 +75,12 @@
|
|||||||
- 提供 App/Window/MainView 基础结构
|
- 提供 App/Window/MainView 基础结构
|
||||||
2. 接入 WebView 控件并完成最小加载闭环
|
2. 接入 WebView 控件并完成最小加载闭环
|
||||||
- 能显示 `index.html`,能打开前端路由
|
- 能显示 `index.html`,能打开前端路由
|
||||||
3. 对齐与 MAUI 端一致的“前端契约”
|
3. 对齐与 MAUI 端一致的"前端契约"
|
||||||
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`)
|
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`)
|
||||||
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
|
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
|
||||||
4. 复用本地 API(内嵌 WebServer)
|
4. 复用本地 API(内嵌 WebServer)
|
||||||
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
|
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
|
||||||
- 明确 Linux 端是否也走“内嵌 WebServer + WebView 指向 BaseUrl”的方式(推荐一致化,减少前端差异)
|
- 明确 Linux 端是否也走"内嵌 WebServer + WebView 指向 BaseUrl"的方式(推荐一致化,减少前端差异)
|
||||||
5. 输入法/字体/DPI 验证与可配置化
|
5. 输入法/字体/DPI 验证与可配置化
|
||||||
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
|
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
|
||||||
|
|
||||||
@@ -88,6 +88,6 @@
|
|||||||
|
|
||||||
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
|
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
|
||||||
- WebView 正常渲染前端主界面
|
- WebView 正常渲染前端主界面
|
||||||
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
|
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少 Todo 待办项列表接口可用)
|
||||||
- 页面路由/刷新不会出现 404(SPA fallback 生效)
|
- 页面路由/刷新不会出现 404(SPA fallback 生效)
|
||||||
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
|
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
|
||||||
+4
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到“用户机器无需手动补大量依赖即可运行”
|
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到"用户机器无需手动补大量依赖即可运行"
|
||||||
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
|
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
|
||||||
|
|
||||||
## 范围
|
## 范围
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
## 交付形式(PRD 约束)
|
## 交付形式(PRD 约束)
|
||||||
|
|
||||||
- 优先提供一种“自包含”官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
|
- 优先提供一种"自包含"官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
|
||||||
- 同时保留 `.deb` 或 `.tar.gz` 作为补充分发形式(以脚本产出为准)
|
- 同时保留 `.deb` 或 `.tar.gz` 作为补充分发形式(以脚本产出为准)
|
||||||
|
|
||||||
## 实施步骤(建议)
|
## 实施步骤(建议)
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
1. 确定 Linux 发行版基线与运行时依赖清单
|
1. 确定 Linux 发行版基线与运行时依赖清单
|
||||||
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
|
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
|
||||||
2. 选择并落地一种自包含交付形式
|
2. 选择并落地一种自包含交付形式
|
||||||
- AppImage:偏“拎包即用”,对依赖捆绑要求高
|
- AppImage:偏"拎包即用",对依赖捆绑要求高
|
||||||
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
|
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
|
||||||
3. 补充 `.deb` 或 `.tar.gz`
|
3. 补充 `.deb` 或 `.tar.gz`
|
||||||
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
|
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 产物可在“干净环境”(尽量接近用户机)安装/运行
|
- 产物可在"干净环境"(尽量接近用户机)安装/运行
|
||||||
- 启动后 WebView 能渲染前端,且本地 API 可用
|
- 启动后 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` 两个安装包。
|
||||||
+10
-9
@@ -1,15 +1,17 @@
|
|||||||
# 03 - Search:任务标题关键词检索
|
# 03 - Search:Todo 待办项标题关键词检索
|
||||||
|
|
||||||
|
> 术语:本工单所指"Todo 待办项 / 任务标题 / 父子任务"为业务实体(Task / SubTask);"工单"指本研发工单本身。
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
- 在主界面顶部增加搜索框
|
- 在主界面顶部增加搜索框
|
||||||
- 支持按任务标题进行模糊匹配(实时过滤)
|
- 支持按 Todo 待办项标题进行模糊匹配(实时过滤)
|
||||||
|
|
||||||
## 范围
|
## 范围
|
||||||
|
|
||||||
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
|
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
|
||||||
- 过滤逻辑:对“树状任务”给出明确策略
|
- 过滤逻辑:对"树状 Todo(父子任务)"给出明确策略
|
||||||
- 性能:任务量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
|
- 性能:Todo 数据量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
@@ -17,15 +19,15 @@
|
|||||||
|
|
||||||
## 过滤策略(需在实现时确定,并写入实现说明)
|
## 过滤策略(需在实现时确定,并写入实现说明)
|
||||||
|
|
||||||
树状任务(父子任务)常见两种策略,任选其一并保持一致:
|
树状 Todo(父子任务)常见两种策略,任选其一并保持一致:
|
||||||
|
|
||||||
1. **命中即显示(含上下文)**
|
1. **命中即显示(含上下文)**
|
||||||
- 任意节点标题命中则显示该节点
|
- 任意节点标题命中则显示该节点
|
||||||
- 若子任务命中,可同时显示其祖先链(便于理解层级)
|
- 若子任务命中,可同时显示其祖先链(便于理解层级)
|
||||||
2. **扁平化命中**
|
2. **扁平化命中**
|
||||||
- 只显示命中的任务(可能丢失层级语义)
|
- 只显示命中的 Todo(可能丢失层级语义)
|
||||||
|
|
||||||
建议优先采用“命中即显示(含上下文)”,用户可更快定位。
|
建议优先采用"命中即显示(含上下文)",用户可更快定位。
|
||||||
|
|
||||||
## 实施步骤(建议)
|
## 实施步骤(建议)
|
||||||
|
|
||||||
@@ -39,7 +41,6 @@
|
|||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 输入关键字后,任务列表实时过滤,仅展示命中结果(符合上述策略)
|
- 输入关键字后,Todo 列表实时过滤,仅展示命中结果(符合上述策略)
|
||||||
- 清空后恢复原列表
|
- 清空后恢复原列表
|
||||||
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
|
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
|
||||||
|
|
||||||
+15
-11
@@ -1,19 +1,21 @@
|
|||||||
# 04 - 云同步(基础可用):服务端基础能力
|
# 04 - 云同步(基础可用):服务端基础能力
|
||||||
|
|
||||||
|
> 术语:本工单文件本身归属"研发工单";文中"任务/Todo 数据"指业务实体(Task / SubTask)。
|
||||||
|
|
||||||
## 目标(PRD 约束)
|
## 目标(PRD 约束)
|
||||||
|
|
||||||
- 支持用户级任务数据同步/读取(用户隔离)
|
- 支持用户级 Todo 待办项数据同步/读取(用户隔离)
|
||||||
- 提供基于角色/权限的访问控制(RBAC)
|
- 提供基于角色/权限的访问控制(RBAC)
|
||||||
- 对高风险操作支持二次认证(能力以服务端实现为准)
|
- 对高风险操作支持二次认证(能力以服务端实现为准)
|
||||||
- 下发“可控落盘”配置(服务端配置驱动)
|
- 下发"可控落盘"配置(服务端配置驱动)
|
||||||
|
|
||||||
## 范围(建议最小闭环)
|
## 范围(建议最小闭环)
|
||||||
|
|
||||||
- 认证(登录/会话)
|
- 认证(登录/会话)
|
||||||
- 任务数据 API(按用户隔离)
|
- Todo 数据 API(按用户隔离)
|
||||||
- 配置下发 API(是否允许落盘、终端可信度/会话策略等)
|
- 配置下发 API(是否允许落盘、终端可信度/会话策略等)
|
||||||
- RBAC 最小落地(至少能区分“允许同步/禁止同步”或“只读/读写”)
|
- RBAC 最小落地(至少能区分"允许同步/禁止同步"或"只读/读写")
|
||||||
- 二次认证最小落地(至少覆盖“启用/关闭同步、切换账号、调整落盘策略”等高风险操作的接口)
|
- 二次认证最小落地(至少覆盖"启用/关闭同步、切换账号、调整落盘策略"等高风险操作的接口)
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
|
|||||||
- Body:`{ "userName": "admin", "password": "..." }`
|
- Body:`{ "userName": "admin", "password": "..." }`
|
||||||
- 200:`{ accessToken, expiresAtUtc, userId, role, permissions[] }`
|
- 200:`{ accessToken, expiresAtUtc, userId, role, permissions[] }`
|
||||||
|
|
||||||
- 二次认证(step-up,v1.2.0 最小实现为“复用登录口令进行再认证”):
|
- 二次认证(step-up,v1.2.0 最小实现为"复用登录口令进行再认证"):
|
||||||
- `POST /auth/step-up`
|
- `POST /auth/step-up`
|
||||||
- Header:`Authorization: Bearer ...`
|
- Header:`Authorization: Bearer ...`
|
||||||
- Body:`{ "password": "..." }`
|
- Body:`{ "password": "..." }`
|
||||||
@@ -61,7 +63,7 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
|
|||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
- 获取当前用户任务全量:
|
- 获取当前用户 Todo 待办项全量:
|
||||||
- `GET /tasks`
|
- `GET /tasks`
|
||||||
- Header:`Authorization: Bearer ...`
|
- Header:`Authorization: Bearer ...`
|
||||||
- 权限:`tasks:read`
|
- 权限:`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. 用户隔离
|
1. 用户隔离
|
||||||
- 服务端所有读写必须绑定当前登录用户上下文
|
- 服务端所有读写必须绑定当前登录用户上下文
|
||||||
2. 权限模型(RBAC)
|
2. 权限模型(RBAC)
|
||||||
- 定义最小角色:例如 `user`、`admin`
|
- 定义最小角色:例如 `user`、`admin`
|
||||||
- 定义最小权限:例如 `tasks:read`、`tasks:write`、`sync:enable`
|
- 定义最小权限:例如 `tasks:read`、`tasks:write`、`sync:enable`
|
||||||
3. 二次认证
|
3. 二次认证
|
||||||
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现“二次口令”)
|
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现"二次口令")
|
||||||
- 能力不足时必须返回明确错误,使客户端可提示用户
|
- 能力不足时必须返回明确错误,使客户端可提示用户
|
||||||
4. 落盘策略下发
|
4. 落盘策略下发
|
||||||
- 支持按用户或按会话/终端下发
|
- 支持按用户或按会话/终端下发
|
||||||
- 默认策略建议为“允许落盘”,便于可用性;但需支持“禁止落盘”
|
- 默认策略建议为"允许落盘",便于可用性;但需支持"禁止落盘"
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 使用不同用户登录后获取任务,数据严格隔离
|
- 使用不同用户登录后获取 Todo 数据,数据严格隔离
|
||||||
- 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别)
|
- 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别)
|
||||||
- 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为
|
- 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为
|
||||||
|
|
||||||
## 当前实现说明(v1.2.0)
|
## 当前实现说明(v1.2.0)
|
||||||
|
|
||||||
- 数据隔离:`Tasks` 表新增 `UserId` 外键,云端 API 的所有读写按当前会话用户隔离;本地模式使用固定的 `local` 用户 ID(不影响既有 Dynamic API)。
|
- 数据隔离:`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。
|
- 二次认证:通过 `POST /auth/step-up` 将会话提升到 step-up 状态;`POST /sync` 与 `PUT /security/policy` 会强制要求 step-up。
|
||||||
+12
-11
@@ -1,21 +1,23 @@
|
|||||||
# 05 - 云同步(基础可用):客户端配置与同步工作流
|
# 05 - 云同步(基础可用):客户端配置与同步工作流
|
||||||
|
|
||||||
|
> 术语:本工单文件归属"研发工单";文中"任务/Todo 数据"指业务实体(Task / SubTask)。
|
||||||
|
|
||||||
## 目标(PRD 约束)
|
## 目标(PRD 约束)
|
||||||
|
|
||||||
- 首次启用同步时,用户需要**手动填写服务端地址**(例如 `https://example.com`),并允许后续在设置中修改
|
- 首次启用同步时,用户需要**手动填写服务端地址**(例如 `https://example.com`),并允许后续在设置中修改
|
||||||
- 客户端对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息
|
- 客户端对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息
|
||||||
- 用户登录成功后,从服务端获取该用户任务并更新到前端展示
|
- 用户登录成功后,从服务端获取该用户 Todo 数据并更新到前端展示
|
||||||
|
|
||||||
## 范围(建议最小闭环)
|
## 范围(建议最小闭环)
|
||||||
|
|
||||||
- “同步设置”入口与界面(服务端地址、登录、同步开关)
|
- "同步设置"入口与界面(服务端地址、登录、同步开关)
|
||||||
- 地址校验与风险提示
|
- 地址校验与风险提示
|
||||||
- 登录后拉取任务并刷新 UI
|
- 登录后拉取 Todo 数据并刷新 UI
|
||||||
- 与本地数据的关系(v1.2.0 建议先做到“服务端为准/或本地为准”的单一策略,避免引入复杂冲突解决)
|
- 与本地数据的关系(v1.2.0 建议先做到"服务端为准/或本地为准"的单一策略,避免引入复杂冲突解决)
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取任务 + 获取安全策略)
|
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取 Todo 数据 + 获取安全策略)
|
||||||
- 与 `03-*`(Search)互不影响,可并行
|
- 与 `03-*`(Search)互不影响,可并行
|
||||||
|
|
||||||
## 关键交互与状态
|
## 关键交互与状态
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
|
|
||||||
## 实施步骤(建议)
|
## 实施步骤(建议)
|
||||||
|
|
||||||
1. 增加“同步设置”UI
|
1. 增加"同步设置"UI
|
||||||
- 放置在主界面可发现位置(例如顶部右侧设置按钮/侧边栏)
|
- 放置在主界面可发现位置(例如顶部右侧设置按钮/侧边栏)
|
||||||
2. 服务端地址配置
|
2. 服务端地址配置
|
||||||
- 基础校验:`https?://`、host 合法性、尾部 `/` 处理规则
|
- 基础校验:`https?://`、host 合法性、尾部 `/` 处理规则
|
||||||
@@ -36,13 +38,12 @@
|
|||||||
3. 登录与凭据存储策略(与 `06-*` 协同)
|
3. 登录与凭据存储策略(与 `06-*` 协同)
|
||||||
- 在允许落盘场景下可持久化 token/会话
|
- 在允许落盘场景下可持久化 token/会话
|
||||||
- 在禁止落盘场景下仅保留内存会话(退出即失效)
|
- 在禁止落盘场景下仅保留内存会话(退出即失效)
|
||||||
4. 登录后拉取任务
|
4. 登录后拉取 Todo 数据
|
||||||
- 拉取成功后更新前端任务列表
|
- 拉取成功后更新前端 Todo 列表
|
||||||
- 拉取失败给出可理解提示,并允许重试
|
- 拉取失败给出可理解提示,并允许重试
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 首次启用同步必须先配置服务端地址;地址非法时不可保存并提示原因
|
- 首次启用同步必须先配置服务端地址;地址非法时不可保存并提示原因
|
||||||
- 保存地址时能提示“不可达/证书异常”等风险信息(至少能区分:成功、失败、存在风险但可继续)
|
- 保存地址时能提示"不可达/证书异常"等风险信息(至少能区分:成功、失败、存在风险但可继续)
|
||||||
- 登录后能从服务端拉取任务并展示在主界面
|
- 登录后能从服务端拉取 Todo 数据并展示在主界面
|
||||||
|
|
||||||
+16
-14
@@ -1,10 +1,12 @@
|
|||||||
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与“可控落盘”
|
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与"可控落盘"
|
||||||
|
|
||||||
|
> 术语:本工单文件归属"研发工单";文中"任务/Todo 数据"指业务实体(Task / SubTask)。
|
||||||
|
|
||||||
## 目标(PRD 约束)
|
## 目标(PRD 约束)
|
||||||
|
|
||||||
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整“是否允许落盘”等)
|
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整"是否允许落盘"等)
|
||||||
- 客户端读取服务端安全配置,决定任务信息是否允许落盘
|
- 客户端读取服务端安全配置,决定 Todo 数据是否允许落盘
|
||||||
- 当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据
|
- 当服务端标记"终端不可信/不允许落盘"时,客户端只能在内存中持有 Todo 数据;退出应用后不保留 Todo 数据
|
||||||
|
|
||||||
## 范围
|
## 范围
|
||||||
|
|
||||||
@@ -13,15 +15,16 @@
|
|||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现)
|
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约)
|
||||||
|
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
|
||||||
- 依赖 `05-*` 的基础登录/同步 UI 入口
|
- 依赖 `05-*` 的基础登录/同步 UI 入口
|
||||||
|
|
||||||
## 关键设计点(v1.2.0 必须明确)
|
## 关键设计点(v1.2.0 必须明确)
|
||||||
|
|
||||||
### 1) “落盘”定义与边界
|
### 1) "落盘"定义与边界
|
||||||
|
|
||||||
需要明确“哪些数据算落盘”,并在禁止落盘时全部避免:
|
需要明确"哪些数据算落盘",并在禁止落盘时全部避免:
|
||||||
- 任务数据(列表/详情)
|
- Todo 数据(列表/详情)
|
||||||
- 同步状态(上次同步时间、待同步队列等)
|
- 同步状态(上次同步时间、待同步队列等)
|
||||||
- 登录凭据(token/refresh token/会话标识)
|
- 登录凭据(token/refresh token/会话标识)
|
||||||
|
|
||||||
@@ -37,20 +40,19 @@
|
|||||||
|
|
||||||
### 3) 二次认证(挑战-响应)
|
### 3) 二次认证(挑战-响应)
|
||||||
|
|
||||||
建议采用“服务端驱动”的挑战流程:
|
建议采用"服务端驱动"的挑战流程:
|
||||||
- 客户端发起高风险操作
|
- 客户端发起高风险操作
|
||||||
- 服务端返回“需要二次认证”的响应(包含 challenge 信息)
|
- 服务端返回"需要二次认证"的响应(包含 challenge 信息)
|
||||||
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
|
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
|
||||||
- 客户端携带证明再次提交
|
- 客户端携带证明再次提交
|
||||||
|
|
||||||
若服务端暂不支持二次认证,也需有“能力探测/降级提示”,避免客户端卡死。
|
若服务端暂不支持二次认证,也需有"能力探测/降级提示",避免客户端卡死。
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 服务端返回 `allowPersist=false` 时:
|
- 服务端返回 `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)机制。
|
||||||
+14
-13
@@ -1,5 +1,7 @@
|
|||||||
# 07 - 文档同步与验收清单(v1.2.0)
|
# 07 - 文档同步与验收清单(v1.2.0)
|
||||||
|
|
||||||
|
> 术语:本工单文件本身归属"研发工单";文中"任务/Todo 待办项"指业务实体(Task / SubTask)。
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
|
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
|
|
||||||
- README:Features、运行方式、API 说明(如涉及)
|
- README:Features、运行方式、API 说明(如涉及)
|
||||||
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
|
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
|
||||||
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
|
- 本目录工单文件:保持与实现同步(必要时更新验收口径与依赖关系)
|
||||||
|
|
||||||
## 必做同步点(结合当前仓库现状)
|
## 必做同步点(结合当前仓库现状)
|
||||||
|
|
||||||
@@ -21,19 +23,18 @@
|
|||||||
## 验收清单(建议逐项勾选)
|
## 验收清单(建议逐项勾选)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
- [ ] Linux 上可启动并打开主界面
|
- [x] Linux 上可启动并打开主界面
|
||||||
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
|
- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
|
||||||
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
|
- [x] 前端能成功请求本地 `/api/*` 并加载 Todo 待办项列表
|
||||||
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
|
- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md)
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
- [ ] 主界面有搜索框
|
- [x] 主界面有搜索框
|
||||||
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
|
- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
|
||||||
- [ ] 清空后恢复原列表
|
- [x] 清空后恢复原列表
|
||||||
|
|
||||||
### 云同步(基础可用)
|
### 云同步(基础可用)
|
||||||
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
|
- [x] 可手动配置服务端地址,并有基础校验与风险提示
|
||||||
- [ ] 登录后能拉取该用户任务并展示
|
- [x] 登录后能拉取该用户的 Todo 数据并展示
|
||||||
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
|
- [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 (新增) │
|
||||||
|
└───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**模式 B:MAUI 嵌入式 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` 写入 DB(session 管理在 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 | **新增** | Host(Vite 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 CloudTaskItem(C# → 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>
|
||||||
|
/// 父任务 ID(Guid)。
|
||||||
|
/// </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 CloudTaskUpsert(C# → 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 默认使用 PascalCase(ABP 标准),通过 `[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 改为 Guid,JSON 序列化为字符串 |
|
||||||
|
| `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>());
|
||||||
|
|
||||||
|
// 待上传的删除任务 ID(Guid 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_Tasks(ABP 规范),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-Wins(LWW)合并**:同一字段的多设备并发修改,以 `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/02-任务同步规则.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,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" "$@"
|
||||||
@@ -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)。
|
||||||
@@ -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
@@ -6,22 +6,32 @@
|
|||||||
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
|
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*)
|
- Avalonia Linux 入口项目需先落地(参见 docs/project/研发工单-v1.2.0/01-*)
|
||||||
- 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链
|
- 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
|
||||||
#>
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[ValidateSet("linux-x64", "linux-arm64")]
|
[ValidateSet("linux-x64", "linux-arm64")]
|
||||||
[string]$RuntimeIdentifier = "linux-x64",
|
[string]$RuntimeIdentifier = "linux-x64",
|
||||||
|
|
||||||
|
[string]$TargetFramework = "net10.0",
|
||||||
|
|
||||||
[switch]$SelfContained,
|
[switch]$SelfContained,
|
||||||
|
|
||||||
[string]$Configuration = "Release"
|
[switch]$SkipProcessStop,
|
||||||
|
|
||||||
|
[switch]$SkipRestore,
|
||||||
|
|
||||||
|
[ValidateSet("Release", "Debug")]
|
||||||
|
[string]$Configuration = "Release",
|
||||||
|
|
||||||
|
[string]$BaseIntermediateOutputPath
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$ScriptPath = $PSScriptRoot
|
$ScriptPath = $PSScriptRoot
|
||||||
|
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
|
||||||
|
|
||||||
function Get-FirstExistingFilePath {
|
function Get-FirstExistingFilePath {
|
||||||
param(
|
param(
|
||||||
@@ -41,10 +51,23 @@ function Get-FirstExistingFilePath {
|
|||||||
function Read-ProjectVersion {
|
function Read-ProjectVersion {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$ProjectFile
|
[string]$ProjectFile,
|
||||||
|
|
||||||
|
[string]$DirectoryBuildProps
|
||||||
)
|
)
|
||||||
|
|
||||||
$currentVersion = "0.0.0"
|
$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
|
[xml]$csproj = Get-Content $ProjectFile -Raw
|
||||||
|
|
||||||
$versionNode = $csproj.SelectSingleNode("//Version")
|
$versionNode = $csproj.SelectSingleNode("//Version")
|
||||||
@@ -60,6 +83,38 @@ function Read-ProjectVersion {
|
|||||||
return $currentVersion
|
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 = @(
|
$candidateProjects = @(
|
||||||
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
|
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
|
||||||
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.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 @"
|
Write-Error @"
|
||||||
未找到 Avalonia Linux 入口项目(无法生成 Linux 交付产物)。
|
未找到 Avalonia Linux 入口项目(无法生成 Linux 交付产物)。
|
||||||
|
|
||||||
请先完成 docs/project/v1.2.0-tasks/01-Linux-Avalonia入口与WebView.md 对应实现,并确保下列路径之一存在:
|
请先完成 docs/project/研发工单-v1.2.0/01-Linux-Avalonia入口与WebView.md 对应实现,并确保下列路径之一存在:
|
||||||
$candidatesText
|
$candidatesText
|
||||||
"@
|
"@
|
||||||
exit 1
|
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"
|
$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier"
|
||||||
$publishDir = Join-Path $artifactRoot "publish"
|
$publishDir = Join-Path $artifactRoot "publish"
|
||||||
|
|
||||||
@@ -88,16 +144,57 @@ New-Item -ItemType Directory -Path $publishDir | Out-Null
|
|||||||
|
|
||||||
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
|
$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 `
|
if (!$SkipRestore.IsPresent) {
|
||||||
-c $Configuration `
|
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
|
||||||
-r $RuntimeIdentifier `
|
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
|
||||||
--self-contained $selfContainedValue `
|
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||||
-o $publishDir
|
# 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-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
|
||||||
Write-Error "dotnet publish failed"
|
$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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+142
-17
@@ -1,32 +1,64 @@
|
|||||||
param(
|
param(
|
||||||
|
[ValidateSet("Maui", "Avalonia")]
|
||||||
|
[string]$AppType = "Maui",
|
||||||
|
|
||||||
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
|
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
|
||||||
|
|
||||||
[string]$RuntimeIdentifier = "win-x64",
|
[string]$RuntimeIdentifier = "win-x64",
|
||||||
|
|
||||||
|
[ValidateSet("Release", "Debug")]
|
||||||
[string]$Configuration = "Release",
|
[string]$Configuration = "Release",
|
||||||
|
|
||||||
[switch]$SkipInnoSetup,
|
[switch]$SkipInnoSetup,
|
||||||
|
|
||||||
[switch]$SkipVersionBump,
|
[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"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$ScriptPath = $PSScriptRoot
|
$ScriptPath = $PSScriptRoot
|
||||||
|
|
||||||
|
if ($AppType -eq "Avalonia") {
|
||||||
|
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Avalonia"
|
||||||
|
$TargetFramework = "net10.0"
|
||||||
|
} else {
|
||||||
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
|
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
|
||||||
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
|
}
|
||||||
|
|
||||||
|
$ProjectFile = Get-ChildItem -Path $ProjectDir -Filter "*.csproj" | Select-Object -First 1 -ExpandProperty FullName
|
||||||
$SetupScript = Join-Path $ProjectDir "setup.iss"
|
$SetupScript = Join-Path $ProjectDir "setup.iss"
|
||||||
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
|
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
|
||||||
|
|
||||||
|
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
|
||||||
|
|
||||||
function Read-ProjectVersion {
|
function Read-ProjectVersion {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$ProjectFile
|
[string]$ProjectFile,
|
||||||
|
|
||||||
|
[string]$DirectoryBuildProps
|
||||||
)
|
)
|
||||||
|
|
||||||
$currentVersion = "0.0.0"
|
$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
|
[xml]$csproj = Get-Content $ProjectFile -Raw
|
||||||
|
|
||||||
$versionNode = $csproj.SelectSingleNode("//Version")
|
$versionNode = $csproj.SelectSingleNode("//Version")
|
||||||
@@ -153,14 +185,93 @@ function Copy-Directory {
|
|||||||
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
|
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
|
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 启用,其它目标关闭。
|
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
|
||||||
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
|
$maxAttempts = 3
|
||||||
if ($LASTEXITCODE -ne 0) {
|
$publishSuccess = $false
|
||||||
Write-Error "MAUI build failed"
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +303,7 @@ if (!(Test-Path $desiredExePath)) {
|
|||||||
$candidateExe = $null
|
$candidateExe = $null
|
||||||
$preferredCandidateNames = @(
|
$preferredCandidateNames = @(
|
||||||
"Hua.Todo.Maui.exe",
|
"Hua.Todo.Maui.exe",
|
||||||
|
"Hua.Todo.Avalonia.exe",
|
||||||
"Hua.Todo.exe"
|
"Hua.Todo.exe"
|
||||||
)
|
)
|
||||||
foreach ($name in $preferredCandidateNames) {
|
foreach ($name in $preferredCandidateNames) {
|
||||||
@@ -217,6 +329,7 @@ if (!(Test-Path $desiredExePath)) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($AppType -eq "Maui") {
|
||||||
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
|
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
|
||||||
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
|
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
|
||||||
if (!(Test-Path $mauiIndexPath)) {
|
if (!(Test-Path $mauiIndexPath)) {
|
||||||
@@ -230,6 +343,7 @@ if (Test-Path $publishWwwroot) {
|
|||||||
}
|
}
|
||||||
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
|
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
|
||||||
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
|
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
$installerPath = $null
|
$installerPath = $null
|
||||||
if (!$SkipInnoSetup.IsPresent) {
|
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).
|
# ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly).
|
||||||
$maxAttempts = 3
|
$maxAttempts = 3
|
||||||
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
|
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
|
||||||
|
Write-Host "Compiling installer (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
|
||||||
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
|
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
|
||||||
$exitCode = $LASTEXITCODE
|
$exitCode = $LASTEXITCODE
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
@@ -286,8 +401,7 @@ if (!$SkipInnoSetup.IsPresent) {
|
|||||||
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
|
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Error "Inno Setup compiler not found"
|
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +416,8 @@ if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
|
|||||||
}
|
}
|
||||||
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
|
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
|
Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,12 +427,22 @@ if (!$SkipVersionBump.IsPresent) {
|
|||||||
if ($versionMatch.Success) {
|
if ($versionMatch.Success) {
|
||||||
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
|
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
|
||||||
|
|
||||||
$content = Get-Content $ProjectFile -Raw
|
# 1. Update Directory.Build.props if exists
|
||||||
if ($content -match "<Version>[^<]*</Version>") {
|
if (Test-Path $DirectoryBuildProps) {
|
||||||
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
$propsContent = Get-Content $DirectoryBuildProps -Raw
|
||||||
Set-Content $ProjectFile -Value $content -Encoding UTF8
|
if ($propsContent -match "<Version>[^<]*</Version>") {
|
||||||
} else {
|
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
||||||
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
|
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 {
|
} else {
|
||||||
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
|
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
|
||||||
|
|||||||
+49
-50
@@ -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"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$ScriptPath = $PSScriptRoot
|
$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"
|
# Read version from project file
|
||||||
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
|
$currentVersion = "1.0.0"
|
||||||
|
[xml]$csproj = Get-Content $ProjectFile -Raw
|
||||||
$shouldPublishWindows = $Windows.IsPresent
|
$versionNode = $csproj.SelectSingleNode("//Version")
|
||||||
$shouldPublishLinux = $Linux.IsPresent
|
if ($null -ne $versionNode) {
|
||||||
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
|
$currentVersion = $versionNode.InnerText
|
||||||
$shouldPublishWindows = $true
|
} else {
|
||||||
$shouldPublishLinux = $true
|
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
|
||||||
|
if ($null -ne $versionNode) {
|
||||||
|
$currentVersion = $versionNode.InnerText
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($shouldPublishWindows) {
|
# Update setup script version with current version before build
|
||||||
if (!(Test-Path $publishWindowsScript)) {
|
if (Test-Path $SetupScript) {
|
||||||
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
|
$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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
& $publishWindowsScript `
|
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
|
||||||
-Configuration $Configuration `
|
if (Test-Path $ISCC) {
|
||||||
-SkipInnoSetup:$SkipWindowsInnoSetup `
|
& $ISCC $SetupScript
|
||||||
-SkipVersionBump:$SkipWindowsVersionBump
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "Setup package created successfully!" -ForegroundColor Green
|
||||||
if ($LASTEXITCODE -ne 0) {
|
} else {
|
||||||
exit $LASTEXITCODE
|
Write-Error "Packaging failed"
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Write-Error "Inno Setup compiler not found"
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($shouldPublishLinux) {
|
$versionParts = $currentVersion.Split(".")
|
||||||
if (!(Test-Path $publishLinuxScript)) {
|
$patch = [int]$versionParts[2] + 1
|
||||||
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
|
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($rid in $LinuxRuntimes) {
|
$content = Get-Content $ProjectFile -Raw
|
||||||
& $publishLinuxScript `
|
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
|
||||||
-RuntimeIdentifier $rid `
|
Set-Content $ProjectFile -Value $content
|
||||||
-SelfContained:$LinuxSelfContained `
|
|
||||||
-Configuration $Configuration
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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) { 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
|
||||||
@@ -40,14 +40,5 @@ public static class ClaimsPrincipalExtensions
|
|||||||
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,5 @@ public static class CloudClaims
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Permission = "perm";
|
public const string Permission = "perm";
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 二次认证状态 Claim。
|
|
||||||
/// </summary>
|
|
||||||
public const string StepUp = "step_up";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,11 +100,6 @@ public class SessionAuthenticationHandler : AuthenticationHandler<Authentication
|
|||||||
claims.Add(new Claim(CloudClaims.Permission, p));
|
claims.Add(new Claim(CloudClaims.Permission, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.StepUpExpiresAtUtc.HasValue && session.StepUpExpiresAtUtc.Value > now)
|
|
||||||
{
|
|
||||||
claims.Add(new Claim(CloudClaims.StepUp, "true"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Text.Json;
|
||||||
using Hua.Todo.Application.CloudSync.Auth;
|
using Hua.Todo.Application.CloudSync.Auth;
|
||||||
using Hua.Todo.Application.CloudSync.Models;
|
using Hua.Todo.Application.CloudSync.Models;
|
||||||
using Hua.Todo.Application.CloudSync.Services;
|
using Hua.Todo.Application.CloudSync.Services;
|
||||||
@@ -21,43 +23,67 @@ public static class CloudSyncEndpointExtensions
|
|||||||
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
|
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
|
||||||
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
|
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
|
||||||
auth.MapPost("/login", LoginAsync).AllowAnonymous();
|
auth.MapPost("/login", LoginAsync).AllowAnonymous();
|
||||||
auth.MapPost("/step-up", StepUpAsync).AllowAnonymous();
|
auth.MapPost("/logout", LogoutAsync).RequireAuthorization();
|
||||||
|
auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization();
|
||||||
|
|
||||||
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
|
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
|
||||||
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
|
tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read");
|
||||||
|
|
||||||
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
|
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
|
||||||
sync.MapPost("/", SyncAsync).AllowAnonymous();
|
sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write");
|
||||||
|
|
||||||
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
|
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
|
||||||
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
|
security.MapGet("/policy", GetPolicyAsync).RequireAuthorization("policy:read");
|
||||||
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
|
security.MapPut("/policy", UpdatePolicyAsync).RequireAuthorization("policy:write");
|
||||||
|
|
||||||
|
var admin = app.MapGroup("/admin").WithTags("CloudSync - Admin").RequireAuthorization("users:manage");
|
||||||
|
admin.MapGet("/users", GetUsersAsync);
|
||||||
|
admin.MapPost("/users", CreateUserAsync);
|
||||||
|
admin.MapPost("/users/{userId}/reset-password", ResetPasswordAsync);
|
||||||
|
admin.MapDelete("/users/{userId}", DeleteUserAsync);
|
||||||
|
admin.MapGet("/sessions", GetSessionsAsync);
|
||||||
|
admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync);
|
||||||
|
admin.MapGet("/audit-logs", GetAuditLogsAsync);
|
||||||
|
|
||||||
|
// 服务端探测端点(允许匿名访问,用于探测用户配置的外部服务端)
|
||||||
|
var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup");
|
||||||
|
probe.MapPost("/probe", ProbeAsync).AllowAnonymous();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (string? ip, string? ua) GetClientInfo(HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var ua = httpContext.Request.Headers.UserAgent.ToString();
|
||||||
|
return (ip, ua);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> BootstrapAdminAsync(
|
private static async Task<IResult> BootstrapAdminAsync(
|
||||||
BootstrapAdminRequest request,
|
BootstrapAdminRequest request,
|
||||||
CloudAuthService authService,
|
CloudAuthService authService,
|
||||||
|
HttpContext httpContext,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
if (request == null || string.IsNullOrWhiteSpace(request.UserName))
|
||||||
{
|
{
|
||||||
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
return CloudApiErrors.BadRequest("UserName is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken);
|
var (ip, ua) = GetClientInfo(httpContext);
|
||||||
if (!ok)
|
var result = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken);
|
||||||
|
if (result == null)
|
||||||
{
|
{
|
||||||
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
|
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok();
|
return Results.Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
CloudAuthService authService,
|
CloudAuthService authService,
|
||||||
|
HttpContext httpContext,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
@@ -65,7 +91,8 @@ public static class CloudSyncEndpointExtensions
|
|||||||
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken);
|
var (ip, ua) = GetClientInfo(httpContext);
|
||||||
|
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, cancellationToken);
|
||||||
if (response == null)
|
if (response == null)
|
||||||
{
|
{
|
||||||
return CloudApiErrors.Unauthorized("Invalid credentials.");
|
return CloudApiErrors.Unauthorized("Invalid credentials.");
|
||||||
@@ -74,8 +101,8 @@ public static class CloudSyncEndpointExtensions
|
|||||||
return Results.Json(response);
|
return Results.Json(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> StepUpAsync(
|
private static async Task<IResult> ChangePasswordAsync(
|
||||||
StepUpRequest request,
|
ChangePasswordRequest request,
|
||||||
CloudAuthService authService,
|
CloudAuthService authService,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -86,18 +113,39 @@ public static class CloudSyncEndpointExtensions
|
|||||||
return CloudApiErrors.Unauthorized();
|
return CloudApiErrors.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request == null || string.IsNullOrWhiteSpace(request.Password))
|
if (request == null || string.IsNullOrWhiteSpace(request.CurrentPassword) || string.IsNullOrWhiteSpace(request.NewPassword))
|
||||||
{
|
{
|
||||||
return CloudApiErrors.BadRequest("Password is required.");
|
return CloudApiErrors.BadRequest("CurrentPassword and NewPassword are required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken);
|
if (request.NewPassword.Length < 8)
|
||||||
if (!expiresAt.HasValue)
|
|
||||||
{
|
{
|
||||||
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
|
return CloudApiErrors.BadRequest("NewPassword must be at least 8 characters.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
|
var (ip, ua) = GetClientInfo(httpContext);
|
||||||
|
var ok = await authService.ChangePasswordAsync(sessionId.Value, request.CurrentPassword, request.NewPassword, ip, ua, cancellationToken);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return CloudApiErrors.BadRequest("Invalid current password or session expired.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> LogoutAsync(
|
||||||
|
CloudAuthService authService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sessionId = httpContext.User.GetSessionId();
|
||||||
|
if (sessionId == null)
|
||||||
|
{
|
||||||
|
return CloudApiErrors.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.LogoutAsync(sessionId.Value, cancellationToken);
|
||||||
|
return Results.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetTasksAsync(
|
private static async Task<IResult> GetTasksAsync(
|
||||||
@@ -137,11 +185,6 @@ public static class CloudSyncEndpointExtensions
|
|||||||
return CloudApiErrors.Forbidden();
|
return CloudApiErrors.Forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!httpContext.User.HasStepUp())
|
|
||||||
{
|
|
||||||
return CloudApiErrors.SecondFactorRequired();
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
|
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
|
||||||
return Results.Json(response);
|
return Results.Json(response);
|
||||||
}
|
}
|
||||||
@@ -183,12 +226,154 @@ public static class CloudSyncEndpointExtensions
|
|||||||
return CloudApiErrors.Forbidden();
|
return CloudApiErrors.Forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!httpContext.User.HasStepUp())
|
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, cancellationToken);
|
||||||
{
|
|
||||||
return CloudApiErrors.SecondFactorRequired();
|
|
||||||
}
|
|
||||||
|
|
||||||
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken);
|
|
||||||
return Results.Json(policy);
|
return Results.Json(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Admin Endpoints
|
||||||
|
|
||||||
|
private static async Task<IResult> GetUsersAsync(
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// TODO: 启用权限校验
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
var users = await adminService.GetUsersAsync(cancellationToken);
|
||||||
|
return Results.Json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateUserAsync(
|
||||||
|
CreateUserRequest request,
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await adminService.CreateUserAsync(request, cancellationToken);
|
||||||
|
if (user == null) return CloudApiErrors.BadRequest("User already exists or creation failed.");
|
||||||
|
|
||||||
|
return Results.Json(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ResetPasswordAsync(
|
||||||
|
Guid userId,
|
||||||
|
ResetPasswordRequest request,
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
if (request == null || string.IsNullOrWhiteSpace(request.NewPassword))
|
||||||
|
{
|
||||||
|
return CloudApiErrors.BadRequest("NewPassword is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = await adminService.ResetPasswordAsync(userId, request.NewPassword, cancellationToken);
|
||||||
|
return ok ? Results.Ok() : CloudApiErrors.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteUserAsync(
|
||||||
|
Guid userId,
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
var ok = await adminService.DeleteUserAsync(userId, cancellationToken);
|
||||||
|
return ok ? Results.Ok() : CloudApiErrors.BadRequest("User not found or deletion not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSessionsAsync(
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
var sessions = await adminService.GetSessionsAsync(cancellationToken);
|
||||||
|
return Results.Json(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RevokeSessionAsync(
|
||||||
|
Guid sessionId,
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
var ok = await adminService.RevokeSessionAsync(sessionId, cancellationToken);
|
||||||
|
return ok ? Results.Ok() : CloudApiErrors.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetAuditLogsAsync(
|
||||||
|
int count,
|
||||||
|
CloudAdminService adminService,
|
||||||
|
HttpContext httpContext,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||||
|
|
||||||
|
if (count <= 0) count = 100;
|
||||||
|
var logs = await adminService.GetAuditLogsAsync(count, cancellationToken);
|
||||||
|
return Results.Json(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ProbeAsync(
|
||||||
|
ProbeRequest request,
|
||||||
|
CloudProbeService probeService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl))
|
||||||
|
{
|
||||||
|
return CloudApiErrors.BadRequest("TargetUrl is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken);
|
||||||
|
return Results.Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册云同步代理设置 API(仅嵌入式 WebServer 使用)。
|
||||||
|
/// GET /api/cloud-sync/settings → 返回当前 URL
|
||||||
|
/// POST /api/cloud-sync/settings → 设置 URL
|
||||||
|
/// </summary>
|
||||||
|
public static WebApplication MapCloudSyncProxySettings(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/cloud-sync").WithTags("CloudSync - Proxy");
|
||||||
|
|
||||||
|
group.MapGet("/settings", (CloudSyncProxySettings settings) =>
|
||||||
|
{
|
||||||
|
return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/settings", async (HttpContext context, CloudSyncProxySettings settings) =>
|
||||||
|
{
|
||||||
|
using var doc = await JsonDocument.ParseAsync(context.Request.Body);
|
||||||
|
var serverUrl = doc.RootElement.TryGetProperty("serverUrl", out var el) ? el.GetString() : null;
|
||||||
|
settings.SetUrl(serverUrl);
|
||||||
|
|
||||||
|
// 持久化到 appsettings.json
|
||||||
|
if (context.RequestServices.GetService<CloudSyncProxySettingsPersistence>() is { } persistence)
|
||||||
|
{
|
||||||
|
try { persistence.Save(settings.GetUrl() ?? string.Empty); } catch { /* 保存失败不影响前端 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.CloudSync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 云同步反向代理中间件。
|
||||||
|
/// 将 /auth/、/tasks/、/sync/、/security/、/cloud-sync/ 请求转发到远程 Host。
|
||||||
|
/// 仅在嵌入式 MAUI/Avalonia WebServer 中注册。
|
||||||
|
/// </summary>
|
||||||
|
public class CloudSyncProxyMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 需要代理的路径前缀集合。
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] ProxyPrefixes = { "/auth/", "/tasks/", "/sync/", "/security/", "/cloud-sync/" };
|
||||||
|
|
||||||
|
public CloudSyncProxyMiddleware(RequestDelegate next, IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 处理请求。如果匹配代理前缀则转发,否则放行到下一中间件。
|
||||||
|
/// </summary>
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var requestPath = context.Request.Path.Value ?? string.Empty;
|
||||||
|
|
||||||
|
// 仅拦截云同步相关路径
|
||||||
|
if (!ProxyPrefixes.Any(p => requestPath.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = context.RequestServices.GetRequiredService<CloudSyncProxySettings>();
|
||||||
|
var targetUrl = settings.GetUrl();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(targetUrl))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
await context.Response.WriteAsync("{\"error\":\"cloud sync server URL not configured\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProxyRequestAsync(context, targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将当前请求转发到目标地址,并回写响应。
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProxyRequestAsync(HttpContext context, string targetUrl)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("CloudSyncProxy");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
var request = context.Request;
|
||||||
|
var targetUri = $"{targetUrl}{request.Path}{request.QueryString}";
|
||||||
|
|
||||||
|
var proxyRequest = new HttpRequestMessage(new HttpMethod(request.Method), targetUri);
|
||||||
|
|
||||||
|
// 复制请求体
|
||||||
|
if (request.ContentLength > 0 || request.Headers.ContainsKey("Transfer-Encoding"))
|
||||||
|
{
|
||||||
|
request.EnableBuffering();
|
||||||
|
request.Body.Position = 0;
|
||||||
|
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
request.Body.Position = 0;
|
||||||
|
proxyRequest.Content = new StringContent(body, System.Text.Encoding.UTF8, request.ContentType ?? "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制 Authorization 头
|
||||||
|
if (request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||||
|
{
|
||||||
|
proxyRequest.Headers.TryAddWithoutValidation("Authorization", authHeader.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制 Content-Type(StringContent 已设置,但确保不被覆盖)
|
||||||
|
if (request.Headers.TryGetValue("Content-Type", out var ctHeader) && proxyRequest.Content != null)
|
||||||
|
{
|
||||||
|
proxyRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ctHeader.First()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
|
||||||
|
|
||||||
|
context.Response.StatusCode = (int)response.StatusCode;
|
||||||
|
context.Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json";
|
||||||
|
|
||||||
|
// 回写响应体
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync(context.RequestAborted);
|
||||||
|
await context.Response.WriteAsync(responseBody, context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CloudSyncProxyMiddleware 的 IApplicationBuilder 扩展。
|
||||||
|
/// </summary>
|
||||||
|
public static class CloudSyncProxyMiddlewareExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 注册云同步反向代理中间件。
|
||||||
|
/// </summary>
|
||||||
|
public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
return builder.UseMiddleware<CloudSyncProxyMiddleware>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Hua.Todo.Application.CloudSync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 云同步代理 URL 管理服务。
|
||||||
|
/// 嵌入式 WebServer 启动时从 appsettings 读取初始值,
|
||||||
|
/// 前端通过 /api/cloud-sync/settings 读写。
|
||||||
|
/// </summary>
|
||||||
|
public class CloudSyncProxySettings
|
||||||
|
{
|
||||||
|
private string? _url;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前配置的云同步服务端地址,未配置时返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public string? GetUrl()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置云同步服务端地址。
|
||||||
|
/// </summary>
|
||||||
|
public void SetUrl(string? url)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_url = string.IsNullOrWhiteSpace(url) ? null : url.TrimEnd('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.CloudSync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 云同步代理 URL 的 appsettings.json 持久化帮助类。
|
||||||
|
/// 由 MAUI/Avalonia 宿主创建并注册,注入 appsettings 文件路径。
|
||||||
|
/// </summary>
|
||||||
|
public class CloudSyncProxySettingsPersistence
|
||||||
|
{
|
||||||
|
private readonly string _settingsFilePath;
|
||||||
|
|
||||||
|
public CloudSyncProxySettingsPersistence(string settingsFilePath)
|
||||||
|
{
|
||||||
|
_settingsFilePath = settingsFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 CloudSyncUrl 写回 appsettings.json。
|
||||||
|
/// </summary>
|
||||||
|
public void Save(string url)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_settingsFilePath);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
using var stream = new FileStream(_settingsFilePath, FileMode.Create, FileAccess.Write);
|
||||||
|
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (prop.NameEquals("WebServer"))
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("WebServer");
|
||||||
|
writer.WriteStartObject();
|
||||||
|
foreach (var wsProp in prop.Value.EnumerateObject())
|
||||||
|
{
|
||||||
|
writer.WritePropertyName(wsProp.Name);
|
||||||
|
if (wsProp.NameEquals("CloudSyncUrl"))
|
||||||
|
{
|
||||||
|
writer.WriteStringValue(url ?? string.Empty);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
wsProp.Value.WriteTo(writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
writer.WritePropertyName(prop.Name);
|
||||||
|
prop.Value.WriteTo(writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,22 @@ public static class CloudSyncServiceCollectionExtensions
|
|||||||
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
|
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
|
||||||
|
|
||||||
services.AddScoped<CloudAuthService>();
|
services.AddScoped<CloudAuthService>();
|
||||||
|
services.AddScoped<CloudAdminService>();
|
||||||
services.AddScoped<CloudTaskSyncService>();
|
services.AddScoped<CloudTaskSyncService>();
|
||||||
services.AddScoped<SecurityPolicyService>();
|
services.AddScoped<SecurityPolicyService>();
|
||||||
|
|
||||||
|
// 注册探测服务专用的 HttpClient(仅限服务端,MAUI 端不注册 AddCloudSyncServer)
|
||||||
|
// 关闭 SSL 证书校验:探测目标可能是自签证书或内网服务器
|
||||||
|
services.AddHttpClient("ProbeClient", client =>
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
|
||||||
|
})
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||||
|
});
|
||||||
|
services.AddScoped<CloudProbeService>();
|
||||||
|
|
||||||
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
|
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
|
||||||
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
|
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
|
||||||
|
|
||||||
@@ -43,5 +56,29 @@ public static class CloudSyncServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册云同步反向代理所需的服务(轻量级,供嵌入式 MAUI/Avalonia WebServer 使用)。
|
||||||
|
/// 仅注册 URL 管理服务与转发专用 HttpClient,不包含认证/授权/业务服务。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">服务集合。</param>
|
||||||
|
/// <returns>服务集合。</returns>
|
||||||
|
public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<CloudSyncProxySettings>();
|
||||||
|
|
||||||
|
// 代理专用的 HttpClient:不跟随重定向,允许转发到自签/内网服务器
|
||||||
|
services.AddHttpClient("CloudSyncProxy", client =>
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0");
|
||||||
|
})
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace Hua.Todo.Application.CloudSync.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户信息 DTO(管理端使用)。
|
||||||
|
/// </summary>
|
||||||
|
public class UserDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAtUtc { get; set; }
|
||||||
|
public DateTime UpdatedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建用户请求。
|
||||||
|
/// </summary>
|
||||||
|
public class CreateUserRequest
|
||||||
|
{
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 重置密码请求。
|
||||||
|
/// </summary>
|
||||||
|
public class ResetPasswordRequest
|
||||||
|
{
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 审计日志 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public DateTime TimestampUtc { get; set; }
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string EventType { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string? ClientIp { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
public bool IsSuccess { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 会话信息 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public class SessionDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAtUtc { get; set; }
|
||||||
|
public DateTime ExpiresAtUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ public class LoginResponse
|
|||||||
/// 用户权限列表。
|
/// 用户权限列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Permissions { get; set; } = new();
|
public List<string> Permissions { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否必须修改密码。
|
||||||
|
/// </summary>
|
||||||
|
public bool MustChangePassword { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -58,30 +63,42 @@ public class BootstrapAdminRequest
|
|||||||
public string UserName { get; set; } = string.Empty;
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 密码。
|
/// 密码(可选,若不提供则自动生成随机密码)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Password { get; set; } = string.Empty;
|
public string? Password { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 二次认证(step-up)请求。
|
/// 初始化管理员账号响应。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StepUpRequest
|
public class BootstrapAdminResponse
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。
|
/// 用户名。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Password { get; set; } = string.Empty;
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成的临时密码(仅在首次创建时返回)。
|
||||||
|
/// </summary>
|
||||||
|
public string GeneratedPassword { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 二次认证(step-up)响应。
|
/// 修改密码请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StepUpResponse
|
public class ChangePasswordRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 二次认证有效期截止(UTC)。
|
/// 当前密码。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime StepUpExpiresAtUtc { get; set; }
|
public string CurrentPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新密码。
|
||||||
|
/// </summary>
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ public static class CloudApiErrors
|
|||||||
public static IResult Forbidden(string message = "Forbidden.")
|
public static IResult Forbidden(string message = "Forbidden.")
|
||||||
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message);
|
=> 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>
|
||||||
/// 请求非法。
|
/// 请求非法。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace Hua.Todo.Application.CloudSync.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务端探测请求。
|
||||||
|
/// </summary>
|
||||||
|
public class ProbeRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 目标 URL。
|
||||||
|
/// </summary>
|
||||||
|
public string TargetUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务端探测响应。
|
||||||
|
/// </summary>
|
||||||
|
public class ProbeResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可达。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsReachable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP 状态码(如果探测失败则为 null)。
|
||||||
|
/// </summary>
|
||||||
|
public int? HttpStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否使用 HTTPS。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsHttps { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页面标题。
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述信息。
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 结果类型:success | warn | error。
|
||||||
|
/// </summary>
|
||||||
|
public string Type { get; set; } = "error";
|
||||||
|
}
|
||||||
@@ -16,9 +16,9 @@ public class SecurityPolicyDto
|
|||||||
public bool AllowSync { get; set; }
|
public bool AllowSync { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 需要二次认证的操作列表(操作码)。
|
/// 是否仅限受信任设备。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> RequireSecondFactorFor { get; set; } = new();
|
public bool IsTrustedDeviceOnly { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -35,4 +35,9 @@ public class UpdateSecurityPolicyRequest
|
|||||||
/// 是否允许同步写入。
|
/// 是否允许同步写入。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AllowSync { get; set; }
|
public bool AllowSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否仅限受信任设备。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsTrustedDeviceOnly { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,140 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Hua.Todo.Core.Entities;
|
using Hua.Todo.Core.Entities;
|
||||||
|
|
||||||
namespace Hua.Todo.Application.CloudSync.Models;
|
namespace Hua.Todo.Application.CloudSync.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 云同步任务条目。
|
/// 云同步任务条目(ABP 标准)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CloudTaskItem
|
public class CloudTaskItem
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 任务 ID(服务端分配)。
|
/// 任务 ID(服务端分配,Guid)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Id { get; set; }
|
[JsonPropertyName("id")]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 标题。
|
/// 标题。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 优先级。
|
/// 优先级。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonPropertyName("priority")]
|
||||||
public TaskPriority Priority { get; set; }
|
public TaskPriority Priority { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否完成。
|
/// 是否完成。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonPropertyName("isCompleted")]
|
||||||
public bool IsCompleted { get; set; }
|
public bool IsCompleted { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建时间(UTC)。
|
/// 任务编号(用户级自增字符串)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime CreatedAtUtc { get; set; }
|
[JsonPropertyName("code")]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新时间(UTC)。
|
/// 父任务 ID(Guid)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime UpdatedAtUtc { get; set; }
|
[JsonPropertyName("parentTaskId")]
|
||||||
|
public Guid? ParentTaskId { get; set; }
|
||||||
|
|
||||||
|
// === ABP 审计字段 ===
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 父任务 ID(v1.2.0 同步可先不使用)。
|
/// 创建时间。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ParentTaskId { get; set; }
|
[JsonPropertyName("creationTime")]
|
||||||
|
public DateTime CreationTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建人 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("creatorId")]
|
||||||
|
public Guid? CreatorId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后修改时间(用于 LWW 冲突判断)。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("lastModificationTime")]
|
||||||
|
public DateTime? LastModificationTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后修改人 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("lastModifierId")]
|
||||||
|
public Guid? LastModifierId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 软删除标记。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("isDeleted")]
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除时间。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("deletionTime")]
|
||||||
|
public DateTime? DeletionTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除人 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("deleterId")]
|
||||||
|
public Guid? DeleterId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 任务 Upsert DTO(客户端提交)。
|
||||||
|
/// </summary>
|
||||||
|
public class CloudTaskUpsert
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 任务 ID;为 null 表示新建。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标题。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优先级。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("priority")]
|
||||||
|
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否完成。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("isCompleted")]
|
||||||
|
public bool IsCompleted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 任务编号。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 父任务 ID(可选)。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("parentTaskId")]
|
||||||
|
public Guid? ParentTaskId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客户端发起变更时写入的时间戳(UTC),用于 LWW 冲突判断。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("lastModificationTime")]
|
||||||
|
public DateTime? LastModificationTime { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -51,43 +145,14 @@ public class SyncRequest
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增或更新的任务列表。
|
/// 新增或更新的任务列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonPropertyName("upserts")]
|
||||||
public List<CloudTaskUpsert> Upserts { get; set; } = new();
|
public List<CloudTaskUpsert> Upserts { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 需要删除的任务 ID 列表。
|
/// 需要逻辑删除的任务 ID 列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<int> Deletes { get; set; } = new();
|
[JsonPropertyName("deletes")]
|
||||||
}
|
public List<Guid> 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>
|
||||||
@@ -98,11 +163,12 @@ public class SyncResponse
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 服务端时间(UTC)。
|
/// 服务端时间(UTC)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonPropertyName("serverTimeUtc")]
|
||||||
public DateTime ServerTimeUtc { get; set; }
|
public DateTime ServerTimeUtc { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当前用户的任务全量。
|
/// 当前用户的任务全量(含 Tombstone)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[JsonPropertyName("tasks")]
|
||||||
public List<CloudTaskItem> Tasks { get; set; } = new();
|
public List<CloudTaskItem> Tasks { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
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 CloudAdminService
|
||||||
|
{
|
||||||
|
private readonly TodoDbContext _dbContext;
|
||||||
|
private readonly IPasswordHasher<UserEntity> _passwordHasher;
|
||||||
|
|
||||||
|
public CloudAdminService(TodoDbContext dbContext, IPasswordHasher<UserEntity> passwordHasher)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_passwordHasher = passwordHasher;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region User Management
|
||||||
|
|
||||||
|
public async Task<List<UserDto>> GetUsersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.Role != "local")
|
||||||
|
.Select(u => new UserDto
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
UserName = u.UserName,
|
||||||
|
Role = u.Role,
|
||||||
|
CreatedAtUtc = u.CreatedAtUtc,
|
||||||
|
UpdatedAtUtc = u.UpdatedAtUtc
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserDto?> CreateUserAsync(CreateUserRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedUserName = request.UserName.Trim();
|
||||||
|
var exists = await _dbContext.Users.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
|
||||||
|
if (exists) return null;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var user = new UserEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = normalizedUserName,
|
||||||
|
Role = request.Role,
|
||||||
|
CreatedAtUtc = now,
|
||||||
|
UpdatedAtUtc = now
|
||||||
|
};
|
||||||
|
|
||||||
|
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
|
||||||
|
_dbContext.Users.Add(user);
|
||||||
|
|
||||||
|
// 为新用户创建默认策略
|
||||||
|
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true });
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new UserDto { Id = user.Id, UserName = user.UserName, Role = user.Role, CreatedAtUtc = user.CreatedAtUtc, UpdatedAtUtc = user.UpdatedAtUtc };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||||
|
if (user == null) return false;
|
||||||
|
|
||||||
|
user.PasswordHash = _passwordHasher.HashPassword(user, newPassword);
|
||||||
|
user.UpdatedAtUtc = DateTime.UtcNow;
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||||
|
if (user == null || user.Role == "admin") return false; // 不允许通过 API 删除最后一个 admin(通常应有更多保护)
|
||||||
|
|
||||||
|
_dbContext.Users.Remove(user);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Session Management
|
||||||
|
|
||||||
|
public async Task<List<SessionDto>> GetSessionsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
return await _dbContext.UserSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(s => s.User)
|
||||||
|
.Where(s => s.ExpiresAtUtc > now)
|
||||||
|
.Select(s => new SessionDto
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
UserId = s.UserId,
|
||||||
|
UserName = s.User != null ? s.User.UserName : "Unknown",
|
||||||
|
CreatedAtUtc = s.CreatedAtUtc,
|
||||||
|
ExpiresAtUtc = s.ExpiresAtUtc
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
||||||
|
if (session == null) return false;
|
||||||
|
|
||||||
|
_dbContext.UserSessions.Remove(session);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Audit Logs
|
||||||
|
|
||||||
|
public async Task<List<AuditLogDto>> GetAuditLogsAsync(int count, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _dbContext.AuditLogs
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(l => l.TimestampUtc)
|
||||||
|
.Take(count)
|
||||||
|
.Select(l => new AuditLogDto
|
||||||
|
{
|
||||||
|
Id = l.Id,
|
||||||
|
TimestampUtc = l.TimestampUtc,
|
||||||
|
UserId = l.UserId,
|
||||||
|
UserName = l.UserName,
|
||||||
|
EventType = l.EventType,
|
||||||
|
Description = l.Description,
|
||||||
|
ClientIp = l.ClientIp,
|
||||||
|
UserAgent = l.UserAgent,
|
||||||
|
IsSuccess = l.IsSuccess
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public class CloudAuthService
|
|||||||
private readonly TodoDbContext _dbContext;
|
private readonly TodoDbContext _dbContext;
|
||||||
private readonly IPasswordHasher<UserEntity> _passwordHasher;
|
private readonly IPasswordHasher<UserEntity> _passwordHasher;
|
||||||
private readonly IRolePermissionMapper _rolePermissionMapper;
|
private readonly IRolePermissionMapper _rolePermissionMapper;
|
||||||
|
private static readonly Random _random = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建 <see cref="CloudAuthService"/>。
|
/// 创建 <see cref="CloudAuthService"/>。
|
||||||
@@ -32,19 +33,75 @@ public class CloudAuthService
|
|||||||
_rolePermissionMapper = rolePermissionMapper;
|
_rolePermissionMapper = rolePermissionMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GenerateRandomPassword(int length = 16)
|
||||||
|
{
|
||||||
|
const string uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const string lowercase = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const string digits = "0123456789";
|
||||||
|
const string special = "!@#$%^&*";
|
||||||
|
var allChars = uppercase + lowercase + digits + special;
|
||||||
|
|
||||||
|
var password = new char[length];
|
||||||
|
password[0] = uppercase[_random.Next(uppercase.Length)];
|
||||||
|
password[1] = lowercase[_random.Next(lowercase.Length)];
|
||||||
|
password[2] = digits[_random.Next(digits.Length)];
|
||||||
|
password[3] = special[_random.Next(special.Length)];
|
||||||
|
|
||||||
|
for (int i = 4; i < length; i++)
|
||||||
|
{
|
||||||
|
password[i] = allChars[_random.Next(allChars.Length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = password.Length - 1; i > 0; i--)
|
||||||
|
{
|
||||||
|
int j = _random.Next(i + 1);
|
||||||
|
(password[i], password[j]) = (password[j], password[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAsync(
|
||||||
|
string eventType,
|
||||||
|
string description,
|
||||||
|
Guid? userId = null,
|
||||||
|
string? userName = null,
|
||||||
|
bool isSuccess = true,
|
||||||
|
string? clientIp = null,
|
||||||
|
string? userAgent = null)
|
||||||
|
{
|
||||||
|
var log = new AuditLogEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
EventType = eventType,
|
||||||
|
Description = description,
|
||||||
|
UserId = userId,
|
||||||
|
UserName = userName,
|
||||||
|
IsSuccess = isSuccess,
|
||||||
|
ClientIp = clientIp,
|
||||||
|
UserAgent = userAgent
|
||||||
|
};
|
||||||
|
|
||||||
|
_dbContext.AuditLogs.Add(log);
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
|
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userName">用户名。</param>
|
/// <param name="userName">用户名。</param>
|
||||||
/// <param name="password">密码。</param>
|
/// <param name="password">密码(可选,若不提供则自动生成)。</param>
|
||||||
|
/// <param name="clientIp">客户端 IP。</param>
|
||||||
|
/// <param name="userAgent">User-Agent。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>是否初始化成功。</returns>
|
/// <returns>初始化结果,包含生成的密码(若自动生成)。</returns>
|
||||||
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
|
public async Task<BootstrapAdminResponse?> BootstrapAdminAsync(string userName, string? password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedUserName = (userName ?? string.Empty).Trim();
|
var normalizedUserName = (userName ?? string.Empty).Trim();
|
||||||
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(normalizedUserName))
|
||||||
{
|
{
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasAnyCloudUser = await _dbContext.Users
|
var hasAnyCloudUser = await _dbContext.Users
|
||||||
@@ -53,7 +110,8 @@ public class CloudAuthService
|
|||||||
|
|
||||||
if (hasAnyCloudUser)
|
if (hasAnyCloudUser)
|
||||||
{
|
{
|
||||||
return false;
|
await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists = await _dbContext.Users
|
var exists = await _dbContext.Users
|
||||||
@@ -62,23 +120,33 @@ public class CloudAuthService
|
|||||||
|
|
||||||
if (exists)
|
if (exists)
|
||||||
{
|
{
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var generatedPassword = password ?? GenerateRandomPassword();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
var user = new UserEntity
|
var user = new UserEntity
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserName = normalizedUserName,
|
UserName = normalizedUserName,
|
||||||
Role = "admin"
|
Role = "admin",
|
||||||
|
CreatedAtUtc = now,
|
||||||
|
UpdatedAtUtc = now,
|
||||||
|
MustChangePassword = true
|
||||||
};
|
};
|
||||||
|
|
||||||
user.PasswordHash = _passwordHasher.HashPassword(user, password);
|
user.PasswordHash = _passwordHasher.HashPassword(user, generatedPassword);
|
||||||
|
|
||||||
_dbContext.Users.Add(user);
|
_dbContext.Users.Add(user);
|
||||||
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
|
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true });
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return true;
|
await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||||
|
return new BootstrapAdminResponse
|
||||||
|
{
|
||||||
|
UserName = normalizedUserName,
|
||||||
|
GeneratedPassword = generatedPassword
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -87,9 +155,11 @@ public class CloudAuthService
|
|||||||
/// <param name="userName">用户名。</param>
|
/// <param name="userName">用户名。</param>
|
||||||
/// <param name="password">密码。</param>
|
/// <param name="password">密码。</param>
|
||||||
/// <param name="sessionTtl">会话有效期。</param>
|
/// <param name="sessionTtl">会话有效期。</param>
|
||||||
|
/// <param name="clientIp">客户端 IP。</param>
|
||||||
|
/// <param name="userAgent">User-Agent。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>登录响应;失败则返回 null。</returns>
|
/// <returns>登录响应;失败则返回 null。</returns>
|
||||||
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
|
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, string? clientIp, string? userAgent, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedUserName = (userName ?? string.Empty).Trim();
|
var normalizedUserName = (userName ?? string.Empty).Trim();
|
||||||
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
|
||||||
@@ -100,12 +170,14 @@ public class CloudAuthService
|
|||||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
|
||||||
if (user == null || user.Role == "local")
|
if (user == null || user.Role == "local")
|
||||||
{
|
{
|
||||||
|
await LogAsync("LoginFailed", "User not found or local user login attempted.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||||
if (verify == PasswordVerificationResult.Failed)
|
if (verify == PasswordVerificationResult.Failed)
|
||||||
{
|
{
|
||||||
|
await LogAsync("LoginFailed", "Invalid password.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,40 +194,49 @@ public class CloudAuthService
|
|||||||
|
|
||||||
_dbContext.UserSessions.Add(session);
|
_dbContext.UserSessions.Add(session);
|
||||||
|
|
||||||
var hasPolicy = await _dbContext.SecurityPolicies
|
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
|
||||||
.AsNoTracking()
|
if (policy == null)
|
||||||
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
|
|
||||||
|
|
||||||
if (!hasPolicy)
|
|
||||||
{
|
{
|
||||||
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
|
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true };
|
||||||
|
_dbContext.SecurityPolicies.Add(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await LogAsync("LoginSuccess", "User logged in.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||||
|
|
||||||
return new LoginResponse
|
return new LoginResponse
|
||||||
{
|
{
|
||||||
AccessToken = session.Id.ToString(),
|
AccessToken = session.Id.ToString(),
|
||||||
ExpiresAtUtc = expiresAt,
|
ExpiresAtUtc = expiresAt,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Role = user.Role,
|
Role = user.Role,
|
||||||
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList()
|
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList(),
|
||||||
|
MustChangePassword = user.MustChangePassword
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通过再输入口令提升会话权限(step-up)。
|
/// <summary>
|
||||||
|
/// 修改用户密码。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sessionId">会话 ID。</param>
|
/// <param name="sessionId">会话 ID。</param>
|
||||||
/// <param name="password">口令。</param>
|
/// <param name="currentPassword">当前密码。</param>
|
||||||
/// <param name="stepUpTtl">二次认证有效期。</param>
|
/// <param name="newPassword">新密码。</param>
|
||||||
|
/// <param name="clientIp">客户端 IP。</param>
|
||||||
|
/// <param name="userAgent">User-Agent。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>有效期截止时间;失败返回 null。</returns>
|
/// <returns>是否修改成功。</returns>
|
||||||
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
|
public async Task<bool> ChangePasswordAsync(Guid sessionId, string currentPassword, string newPassword, string? clientIp, string? userAgent, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(currentPassword) || string.IsNullOrWhiteSpace(newPassword))
|
||||||
{
|
{
|
||||||
return null;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.Length < 8)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
@@ -163,26 +244,45 @@ public class CloudAuthService
|
|||||||
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
||||||
if (session == null || session.ExpiresAtUtc <= now)
|
if (session == null || session.ExpiresAtUtc <= now)
|
||||||
{
|
{
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, currentPassword);
|
||||||
if (verify == PasswordVerificationResult.Failed)
|
if (verify == PasswordVerificationResult.Failed)
|
||||||
{
|
{
|
||||||
return null;
|
await LogAsync("ChangePasswordFailed", "Invalid current password.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var stepUpExpiresAt = now.Add(stepUpTtl);
|
user.PasswordHash = _passwordHasher.HashPassword(user, newPassword);
|
||||||
session.StepUpExpiresAtUtc = stepUpExpiresAt;
|
user.MustChangePassword = false;
|
||||||
|
user.UpdatedAtUtc = DateTime.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return stepUpExpiresAt;
|
await LogAsync("ChangePasswordSuccess", "Password changed successfully.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 登出:从数据库移除当前会话,使 Token 立即失效。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">会话 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>是否成功移除。</returns>
|
||||||
|
public async Task<bool> LogoutAsync(Guid sessionId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
||||||
|
if (session == null) return false;
|
||||||
|
|
||||||
|
_dbContext.UserSessions.Remove(session);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using Hua.Todo.Application.CloudSync.Models;
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.CloudSync.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。
|
||||||
|
/// </summary>
|
||||||
|
public class CloudProbeService
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建 <see cref="CloudProbeService"/>。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpClientFactory">HTTP 客户端工厂。</param>
|
||||||
|
public CloudProbeService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 探测目标 URL 的可达性。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetUrl">目标 URL。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>探测响应。</returns>
|
||||||
|
public async Task<ProbeResponse> ProbeAsync(string targetUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = new ProbeResponse();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(targetUrl))
|
||||||
|
{
|
||||||
|
response.Type = "error";
|
||||||
|
response.Description = "目标地址不能为空。";
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri uri;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
uri = new Uri(targetUrl);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
response.Type = "error";
|
||||||
|
response.Description = "目标地址格式不正确,请包含协议(http:// 或 https://)。";
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.Scheme != "http" && uri.Scheme != "https")
|
||||||
|
{
|
||||||
|
response.Type = "error";
|
||||||
|
response.Description = "仅支持 http:// 或 https:// 地址。";
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.IsHttps = uri.Scheme == "https";
|
||||||
|
|
||||||
|
// 构造探测 URL:使用 {origin}/api 而非根路径,避免因根路由缺失返回 404
|
||||||
|
var probeUri = new Uri(uri, "/api");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("ProbeClient");
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, probeUri);
|
||||||
|
request.Headers.UserAgent.ParseAdd("HuaTodo-Probe/1.0");
|
||||||
|
|
||||||
|
using var httpResponse = await client.SendAsync(request, cts.Token);
|
||||||
|
response.IsReachable = true;
|
||||||
|
response.HttpStatus = (int)httpResponse.StatusCode;
|
||||||
|
|
||||||
|
if (httpResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (!response.IsHttps)
|
||||||
|
{
|
||||||
|
response.Type = "warn";
|
||||||
|
response.Title = "可达(建议 HTTPS)";
|
||||||
|
response.Description = $"服务器响应 HTTP {response.HttpStatus},当前使用明文传输,建议切换为 HTTPS。";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response.Type = "success";
|
||||||
|
response.Title = "可达";
|
||||||
|
response.Description = $"服务器响应 HTTP {response.HttpStatus},连接正常,可继续登录。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 非 2xx 也说明服务器可达,仅作为提示
|
||||||
|
if (!response.IsHttps)
|
||||||
|
{
|
||||||
|
response.Type = "warn";
|
||||||
|
response.Title = "可达(建议 HTTPS)";
|
||||||
|
response.Description = $"服务器响应 HTTP {response.HttpStatus}(非成功状态码),当前使用明文传输,建议切换为 HTTPS。";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response.Type = "success";
|
||||||
|
response.Title = "可达";
|
||||||
|
response.Description = $"服务器响应 HTTP {response.HttpStatus}(非成功状态码),但连接正常,可继续登录。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
response.IsReachable = false;
|
||||||
|
response.Type = response.IsHttps ? "error" : "warn";
|
||||||
|
response.Title = "存在风险";
|
||||||
|
response.Description = "探测超时(网络不可达或目标无响应)。";
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
response.IsReachable = false;
|
||||||
|
response.Type = response.IsHttps ? "error" : "warn";
|
||||||
|
response.Title = "存在风险";
|
||||||
|
response.Description = response.IsHttps
|
||||||
|
? $"探测失败:{ex.Message}"
|
||||||
|
: $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。";
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
response.IsReachable = false;
|
||||||
|
response.Type = "error";
|
||||||
|
response.Description = "探测过程中发生未知错误。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Hua.Todo.Application.CloudSync.Models;
|
using Hua.Todo.Application.CloudSync.Models;
|
||||||
using Hua.Todo.Application.Data;
|
using Hua.Todo.Application.Data;
|
||||||
using Hua.Todo.Core.Entities;
|
using Hua.Todo.Core.Entities;
|
||||||
@@ -7,6 +8,7 @@ namespace Hua.Todo.Application.CloudSync.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 云同步任务服务(按用户隔离)。
|
/// 云同步任务服务(按用户隔离)。
|
||||||
|
/// 实现 ABP 风格审计字段和 LWW 冲突解决策略。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CloudTaskSyncService
|
public class CloudTaskSyncService
|
||||||
{
|
{
|
||||||
@@ -22,17 +24,19 @@ public class CloudTaskSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取指定用户的任务全量。
|
/// 获取指定用户的任务全量(含 Tombstone)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">用户 ID。</param>
|
/// <param name="userId">用户 ID。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>任务列表。</returns>
|
/// <returns>任务列表(含已删除任务)。</returns>
|
||||||
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
|
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// 使用 IgnoreQueryFilters 获取包括已删除任务的全量
|
||||||
var tasks = await _dbContext.Tasks
|
var tasks = await _dbContext.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.Where(t => t.UserId == userId)
|
.Where(t => t.UserId == userId)
|
||||||
.OrderByDescending(t => t.CreatedAt)
|
.OrderByDescending(t => t.CreationTime)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return tasks.Select(MapToItem).ToList();
|
return tasks.Select(MapToItem).ToList();
|
||||||
@@ -40,6 +44,9 @@ public class CloudTaskSyncService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行同步(增改删),并返回最新全量。
|
/// 执行同步(增改删),并返回最新全量。
|
||||||
|
/// - 对 deletes 递归标记为已删除(Tombstone)
|
||||||
|
/// - 对 upserts 按 lastModificationTime 降序处理,较新的先处理
|
||||||
|
/// - LWW 冲突解决:客户端 lastModificationTime >= 服务端时接受客户端版本
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">用户 ID。</param>
|
/// <param name="userId">用户 ID。</param>
|
||||||
/// <param name="request">同步请求。</param>
|
/// <param name="request">同步请求。</param>
|
||||||
@@ -49,61 +56,111 @@ public class CloudTaskSyncService
|
|||||||
{
|
{
|
||||||
request ??= new SyncRequest();
|
request ??= new SyncRequest();
|
||||||
|
|
||||||
|
// 1. 处理删除(逻辑删除,Tombstone)
|
||||||
if (request.Deletes.Count > 0)
|
if (request.Deletes.Count > 0)
|
||||||
{
|
{
|
||||||
var deleteIds = request.Deletes.Distinct().ToList();
|
var deleteIds = request.Deletes.Distinct().ToList();
|
||||||
var toDelete = await _dbContext.Tasks
|
foreach (var id in deleteIds)
|
||||||
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (toDelete.Count > 0)
|
|
||||||
{
|
{
|
||||||
_dbContext.Tasks.RemoveRange(toDelete);
|
await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存删除操作
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理)
|
||||||
if (request.Upserts.Count > 0)
|
if (request.Upserts.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var upsert in request.Upserts)
|
// 去重策略:
|
||||||
|
// - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送)
|
||||||
|
// - 全新任务(Id == null):全部保留,不参与去重
|
||||||
|
var dedupedById = request.Upserts
|
||||||
|
.Where(u => u.Id.HasValue)
|
||||||
|
.GroupBy(u => u.Id!.Value)
|
||||||
|
.Select(g => g.Last());
|
||||||
|
|
||||||
|
var newTasks = request.Upserts
|
||||||
|
.Where(u => !u.Id.HasValue);
|
||||||
|
|
||||||
|
var uniqueUpserts = dedupedById
|
||||||
|
.Concat(newTasks)
|
||||||
|
.Where(u => !string.IsNullOrWhiteSpace(u.Title))
|
||||||
|
.OrderByDescending(u => u.LastModificationTime) // 较新的先处理
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 使用事务包裹,确保读取与写入的一致性
|
||||||
|
// 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突
|
||||||
|
// ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景;
|
||||||
|
// 对于极端并发写入冲突,SaveChangesAsync 处有重试保护
|
||||||
|
const int maxSaveRetries = 3;
|
||||||
|
|
||||||
|
for (int retry = 0; ; retry++)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(upsert.Title))
|
using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
continue;
|
// 在事务内查询已有任务,确保读取到最新已提交数据
|
||||||
|
var existingTasks = await _dbContext.Tasks
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(t => t.UserId == userId)
|
||||||
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|
||||||
|
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射)
|
||||||
|
var clientIdToEntity = new Dictionary<Guid, TaskEntity>();
|
||||||
|
|
||||||
|
// 第一遍:处理根任务(ParentTaskId == null)
|
||||||
|
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
|
||||||
|
{
|
||||||
|
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (upsert.Id.HasValue)
|
// 保存根任务,获取服务器分配的 ID
|
||||||
{
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
var existing = await _dbContext.Tasks
|
|
||||||
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
|
|
||||||
|
|
||||||
if (existing != null)
|
// 将新创建的根任务加入 existingTasks,供子任务反查
|
||||||
|
foreach (var kvp in clientIdToEntity)
|
||||||
{
|
{
|
||||||
existing.Title = upsert.Title.Trim();
|
existingTasks[kvp.Value.Id] = kvp.Value;
|
||||||
existing.Priority = upsert.Priority;
|
|
||||||
existing.IsCompleted = upsert.IsCompleted;
|
|
||||||
existing.ParentTaskId = upsert.ParentTaskId;
|
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var entity = new TaskEntity
|
// 第二遍:处理子任务(ParentTaskId != null)
|
||||||
|
foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue))
|
||||||
{
|
{
|
||||||
UserId = userId,
|
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
|
||||||
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);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
break; // 成功,退出重试循环
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (retry < maxSaveRetries - 1
|
||||||
|
&& ex.InnerException is Microsoft.Data.Sqlite.SqliteException se
|
||||||
|
&& se.SqliteErrorCode == 19
|
||||||
|
&& se.Message.Contains("UNIQUE constraint"))
|
||||||
|
{
|
||||||
|
// 并发写入冲突:其他事务已插入相同 Id 的任务
|
||||||
|
// 回滚当前事务,下次循环将重新查询并正确识别已存在实体
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 清理 ChangeTracker,避免下次循环携带已回滚的实体状态
|
||||||
|
foreach (var entry in _dbContext.ChangeTracker.Entries<TaskEntity>().ToList())
|
||||||
|
{
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue; // 重试
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new SyncResponse
|
return new SyncResponse
|
||||||
{
|
{
|
||||||
ServerTimeUtc = DateTime.UtcNow,
|
ServerTimeUtc = DateTime.UtcNow,
|
||||||
@@ -111,6 +168,163 @@ public class CloudTaskSyncService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归软删除任务及其子任务。
|
||||||
|
/// </summary>
|
||||||
|
private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var task = await _dbContext.Tasks
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == taskId, cancellationToken);
|
||||||
|
|
||||||
|
if (task != null && !task.IsDeleted)
|
||||||
|
{
|
||||||
|
task.IsDeleted = true;
|
||||||
|
task.DeletionTime = DateTime.UtcNow;
|
||||||
|
task.DeleterId = userId;
|
||||||
|
|
||||||
|
// 显式标记为已修改
|
||||||
|
_dbContext.Entry(task).State = EntityState.Modified;
|
||||||
|
|
||||||
|
// 递归删除子任务
|
||||||
|
var childTasks = await _dbContext.Tasks
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(t => t.ParentTaskId == taskId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var child in childTasks)
|
||||||
|
{
|
||||||
|
await SoftDeleteTaskRecursiveAsync(userId, child.Id, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 处理单个 upsert 请求(创建或更新)。
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessUpsertAsync(
|
||||||
|
Guid userId,
|
||||||
|
CloudTaskUpsert upsert,
|
||||||
|
Dictionary<Guid, TaskEntity> existingTasks,
|
||||||
|
Dictionary<Guid, TaskEntity> clientIdToEntity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID
|
||||||
|
Guid? parentId = upsert.ParentTaskId;
|
||||||
|
if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity))
|
||||||
|
{
|
||||||
|
parentId = parentEntity.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskEntity? entity = null;
|
||||||
|
bool shouldAdd = false;
|
||||||
|
|
||||||
|
// 尝试从已缓存的字典中查找
|
||||||
|
TaskEntity? existingEntity = null;
|
||||||
|
bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity);
|
||||||
|
|
||||||
|
if (!foundInCache && upsert.Id.HasValue)
|
||||||
|
{
|
||||||
|
// 字典未命中,但客户端提供了 Id —— 可能是跨请求/跨 UserId 的遗留数据
|
||||||
|
// 直接查询 DB 以确保不会重复插入
|
||||||
|
existingEntity = await _dbContext.Tasks
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == upsert.Id.Value, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingEntity != null)
|
||||||
|
{
|
||||||
|
// 实体已存在(字典或 DB 中),进行 LWW 冲突判断
|
||||||
|
if (upsert.LastModificationTime.HasValue &&
|
||||||
|
(!existingEntity.LastModificationTime.HasValue ||
|
||||||
|
upsert.LastModificationTime >= existingEntity.LastModificationTime))
|
||||||
|
{
|
||||||
|
// 客户端版本更新,接受更新
|
||||||
|
entity = existingEntity;
|
||||||
|
entity.Title = (upsert.Title ?? string.Empty).Trim();
|
||||||
|
entity.Priority = upsert.Priority;
|
||||||
|
entity.IsCompleted = upsert.IsCompleted;
|
||||||
|
entity.Code = upsert.Code ?? string.Empty;
|
||||||
|
entity.ParentTaskId = parentId;
|
||||||
|
entity.LastModificationTime = upsert.LastModificationTime;
|
||||||
|
entity.LastModifierId = userId;
|
||||||
|
|
||||||
|
// 若实体来自 AsNoTracking 查询(非 tracked),需显式附加并标记为 Modified
|
||||||
|
var entry = _dbContext.Entry(entity);
|
||||||
|
if (entry.State == EntityState.Detached)
|
||||||
|
{
|
||||||
|
_dbContext.Tasks.Attach(entity);
|
||||||
|
entry.State = EntityState.Modified;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry.State = EntityState.Modified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 否则保持服务端版本不变,不做任何操作
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 确认不存在,新建任务
|
||||||
|
entity = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = upsert.Id ?? Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Title = (upsert.Title ?? string.Empty).Trim(),
|
||||||
|
Priority = upsert.Priority,
|
||||||
|
IsCompleted = upsert.IsCompleted,
|
||||||
|
Code = upsert.Code ?? string.Empty,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
CreationTime = DateTime.UtcNow,
|
||||||
|
CreatorId = userId,
|
||||||
|
LastModificationTime = upsert.LastModificationTime ?? DateTime.UtcNow,
|
||||||
|
LastModifierId = userId,
|
||||||
|
};
|
||||||
|
shouldAdd = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldAdd && entity != null)
|
||||||
|
{
|
||||||
|
// 防御性检查:确保实体 Id 未被 ChangeTracker 跟踪,防止 UNIQUE 约束冲突
|
||||||
|
var trackedEntry = _dbContext.ChangeTracker.Entries<TaskEntity>()
|
||||||
|
.FirstOrDefault(e => e.Entity.Id == entity.Id);
|
||||||
|
|
||||||
|
if (trackedEntry != null)
|
||||||
|
{
|
||||||
|
// 实体已被跟踪(可能来自并发操作或其他路径),使用已跟踪的实体
|
||||||
|
entity = trackedEntry.Entity;
|
||||||
|
if (trackedEntry.State != EntityState.Added)
|
||||||
|
{
|
||||||
|
// 仅更新非新增实体的字段
|
||||||
|
entity.Title = (upsert.Title ?? string.Empty).Trim();
|
||||||
|
entity.Priority = upsert.Priority;
|
||||||
|
entity.IsCompleted = upsert.IsCompleted;
|
||||||
|
entity.Code = upsert.Code ?? string.Empty;
|
||||||
|
entity.ParentTaskId = parentId;
|
||||||
|
entity.LastModificationTime = upsert.LastModificationTime ?? DateTime.UtcNow;
|
||||||
|
entity.LastModifierId = userId;
|
||||||
|
trackedEntry.State = EntityState.Modified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dbContext.Tasks.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即更新 existingTasks,避免同一批次内重复插入相同 Id
|
||||||
|
existingTasks[entity.Id] = entity;
|
||||||
|
|
||||||
|
if (upsert.Id.HasValue)
|
||||||
|
{
|
||||||
|
clientIdToEntity[upsert.Id.Value] = entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将实体映射到 DTO。
|
||||||
|
/// </summary>
|
||||||
private static CloudTaskItem MapToItem(TaskEntity task)
|
private static CloudTaskItem MapToItem(TaskEntity task)
|
||||||
{
|
{
|
||||||
return new CloudTaskItem
|
return new CloudTaskItem
|
||||||
@@ -119,10 +333,15 @@ public class CloudTaskSyncService
|
|||||||
Title = task.Title,
|
Title = task.Title,
|
||||||
Priority = task.Priority,
|
Priority = task.Priority,
|
||||||
IsCompleted = task.IsCompleted,
|
IsCompleted = task.IsCompleted,
|
||||||
CreatedAtUtc = task.CreatedAt,
|
Code = task.Code,
|
||||||
UpdatedAtUtc = task.UpdatedAt,
|
ParentTaskId = task.ParentTaskId,
|
||||||
ParentTaskId = task.ParentTaskId
|
CreationTime = task.CreationTime,
|
||||||
|
CreatorId = task.CreatorId,
|
||||||
|
LastModificationTime = task.LastModificationTime,
|
||||||
|
LastModifierId = task.LastModifierId,
|
||||||
|
IsDeleted = task.IsDeleted,
|
||||||
|
DeletionTime = task.DeletionTime,
|
||||||
|
DeleterId = task.DeleterId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class SecurityPolicyService
|
|||||||
{
|
{
|
||||||
AllowPersist = policy?.AllowPersist ?? true,
|
AllowPersist = policy?.AllowPersist ?? true,
|
||||||
AllowSync = policy?.AllowSync ?? true,
|
AllowSync = policy?.AllowSync ?? true,
|
||||||
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
|
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +46,30 @@ public class SecurityPolicyService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">用户 ID。</param>
|
/// <param name="userId">用户 ID。</param>
|
||||||
/// <param name="allowPersist">是否允许落盘。</param>
|
/// <param name="allowPersist">是否允许落盘。</param>
|
||||||
|
/// <param name="allowSync">是否允许同步。</param>
|
||||||
|
/// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>更新后的策略 DTO。</returns>
|
/// <returns>更新后的策略 DTO。</returns>
|
||||||
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
|
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, bool isTrustedDeviceOnly, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
|
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync };
|
policy = new SecurityPolicyEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AllowPersist = allowPersist,
|
||||||
|
AllowSync = allowSync,
|
||||||
|
IsTrustedDeviceOnly = isTrustedDeviceOnly
|
||||||
|
};
|
||||||
_dbContext.SecurityPolicies.Add(policy);
|
_dbContext.SecurityPolicies.Add(policy);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
policy.AllowPersist = allowPersist;
|
policy.AllowPersist = allowPersist;
|
||||||
policy.AllowSync = allowSync;
|
policy.AllowSync = allowSync;
|
||||||
|
policy.IsTrustedDeviceOnly = isTrustedDeviceOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Data.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601(Round-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,21 +21,6 @@ public class TodoDbContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<TaskEntity> Tasks { get; set; }
|
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>
|
||||||
/// 配置实体模型映射。
|
/// 配置实体模型映射。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,58 +33,16 @@ public class TodoDbContext : DbContext
|
|||||||
{
|
{
|
||||||
entity.ToTable("Tasks");
|
entity.ToTable("Tasks");
|
||||||
entity.HasKey(e => e.Id);
|
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.Title).IsRequired().HasMaxLength(200);
|
||||||
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
|
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
|
||||||
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
|
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
|
||||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
|
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
|
||||||
entity.Property(e => e.UpdatedAt).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)
|
entity.HasOne(e => e.ParentTask)
|
||||||
.WithMany(e => e.SubTasks)
|
.WithMany(e => e.SubTasks)
|
||||||
.HasForeignKey(e => e.ParentTaskId)
|
.HasForeignKey(e => e.ParentTaskId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,10 @@
|
|||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
|
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
|
||||||
<Compile Remove="DynamicApi\\**\\*.cs" />
|
<Compile Remove="DynamicApi\\**\\*.cs" />
|
||||||
<Compile Remove="CloudSync\\**\\*.cs" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Hua.Todo.Application.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TodoDbContext))]
|
||||||
|
[Migration("20260413140347_UpdateSecurityEntities")]
|
||||||
|
partial class UpdateSecurityEntities
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowPersist")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowSync")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrustedDeviceOnly")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SecondFactorExpiryMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SecurityPolicies", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int?>("ParentTaskId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ExpiresAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StepUpExpiresAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||||
|
.WithMany("SubTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("SubTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UpdateSecurityEntities : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CreatedAtUtc",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UpdatedAtUtc",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsTrustedDeviceOnly",
|
||||||
|
table: "SecurityPolicies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SecondFactorExpiryMinutes",
|
||||||
|
table: "SecurityPolicies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAtUtc",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedAtUtc",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsTrustedDeviceOnly",
|
||||||
|
table: "SecurityPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SecondFactorExpiryMinutes",
|
||||||
|
table: "SecurityPolicies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Hua.Todo.Application.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TodoDbContext))]
|
||||||
|
[Migration("20260413140753_AddAuditLogs")]
|
||||||
|
partial class AddAuditLogs
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIp")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuccess")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TimestampUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowPersist")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowSync")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrustedDeviceOnly")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int>("SecondFactorExpiryMinutes")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(30);
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SecurityPolicies", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int?>("ParentTaskId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ExpiresAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StepUpExpiresAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||||
|
.WithMany("SubTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("SubTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuditLogs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "SecondFactorExpiryMinutes",
|
||||||
|
table: "SecurityPolicies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 30,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "INTEGER");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsTrustedDeviceOnly",
|
||||||
|
table: "SecurityPolicies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "INTEGER");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AuditLogs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
TimestampUtc = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
UserName = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||||
|
ClientIp = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||||
|
IsSuccess = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AuditLogs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_TimestampUtc",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "TimestampUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AuditLogs_UserId",
|
||||||
|
table: "AuditLogs",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AuditLogs");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "SecondFactorExpiryMinutes",
|
||||||
|
table: "SecurityPolicies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "INTEGER",
|
||||||
|
oldDefaultValue: 30);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsTrustedDeviceOnly",
|
||||||
|
table: "SecurityPolicies",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "INTEGER",
|
||||||
|
oldDefaultValue: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Hua.Todo.Application.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TodoDbContext))]
|
||||||
|
[Migration("20260424164713_AddPasswordSaltToUsers")]
|
||||||
|
partial class AddPasswordSaltToUsers
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIp")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuccess")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TimestampUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowPersist")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowSync")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrustedDeviceOnly")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int>("SecondFactorExpiryMinutes")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(30);
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SecurityPolicies", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int?>("ParentTaskId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ExpiresAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StepUpExpiresAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||||
|
.WithMany("SubTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("SubTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPasswordSaltToUsers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PasswordSalt",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordSalt",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+276
@@ -0,0 +1,276 @@
|
|||||||
|
// <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("20260510171230_AddMustChangePasswordToUsers")]
|
||||||
|
partial class AddMustChangePasswordToUsers
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIp")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuccess")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TimestampUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowPersist")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowSync")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrustedDeviceOnly")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int>("SecondFactorExpiryMinutes")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(30);
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SecurityPolicies", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int?>("ParentTaskId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedAt")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<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)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ExpiresAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StepUpExpiresAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||||
|
.WithMany("SubTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("SubTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMustChangePasswordToUsers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "MustChangePassword",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MustChangePassword",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
// <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("MakeTaskEntityAbpCompatible")]
|
||||||
|
partial class MakeTaskEntityAbpCompatible
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIp")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuccess")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TimestampUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TimestampUtc");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowPersist")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("AllowSync")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrustedDeviceOnly")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<int>("SecondFactorExpiryMinutes")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(30);
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("SecurityPolicies", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.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<DateTime?>("DeletionTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeleterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraProperties")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCompleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastModificationTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LastModifierId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("T_Tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<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)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ExpiresAtUtc")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StepUpExpiresAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserSessions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||||
|
.WithMany("SubTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("SubTasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Hua.Todo.Application.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <summary>
|
||||||
|
/// 重构 TaskEntity 继承 ABP 风格审计基类。
|
||||||
|
/// - 主键从 int 改为 Guid
|
||||||
|
/// - ParentTaskId 从 int? 改为 Guid?
|
||||||
|
/// - 新增 ABP 审计字段:ExtraProperties, ConcurrencyStamp, CreationTime, CreatorId,
|
||||||
|
/// LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId
|
||||||
|
/// - 重命名 CreatedAt -> CreationTime, UpdatedAt -> LastModificationTime
|
||||||
|
/// - 表名从 Tasks 改为 T_Tasks
|
||||||
|
/// </summary>
|
||||||
|
public partial class MakeTaskEntityAbpCompatible : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1. 创建临时表 T_Tasks(新结构)
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "T_Tasks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
ExtraProperties = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CreationTime = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
|
||||||
|
CreatorId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
LastModificationTime = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
LastModifierId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||||
|
DeletionTime = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
DeleterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: false, defaultValue: "local"),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
|
||||||
|
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||||
|
Code = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
|
||||||
|
ParentTaskId = table.Column<Guid>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_T_Tasks", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_T_Tasks_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_T_Tasks_T_Tasks_ParentTaskId",
|
||||||
|
column: x => x.ParentTaskId,
|
||||||
|
principalTable: "T_Tasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 创建索引
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_T_Tasks_ParentTaskId",
|
||||||
|
table: "T_Tasks",
|
||||||
|
column: "ParentTaskId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_T_Tasks_UserId",
|
||||||
|
table: "T_Tasks",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
// 3. 数据迁移(int Id -> Guid,CreatedAt -> CreationTime,UpdatedAt -> LastModificationTime)
|
||||||
|
// 注意:原有数据会丢失,因为 int 无法直接转换为 Guid
|
||||||
|
// 如需保留数据,需先导出再导入
|
||||||
|
|
||||||
|
// 4. 先禁用外键检查,再重命名旧表(避免外键约束失败)
|
||||||
|
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;");
|
||||||
|
migrationBuilder.RenameTable(name: "Tasks", newName: "Tasks_Old");
|
||||||
|
migrationBuilder.Sql("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
|
// 注意:不重命名 T_Tasks 为 Tasks,因为 TodoDbContext 配置的表名是 T_Tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 此回滚为破坏性操作,数据会丢失
|
||||||
|
// 重建旧结构 Tasks 表(仅用于回滚,实际数据不可恢复)
|
||||||
|
|
||||||
|
// 1. 禁用外键检查
|
||||||
|
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;");
|
||||||
|
|
||||||
|
// 2. 删除新表
|
||||||
|
migrationBuilder.DropTable(name: "T_Tasks");
|
||||||
|
|
||||||
|
// 3. 重建旧结构 Tasks 表
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Tasks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: false, defaultValue: "local"),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
|
||||||
|
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||||
|
CreatedAt = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
|
||||||
|
UpdatedAt = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
|
||||||
|
ParentTaskId = table.Column<int>(type: "INTEGER", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Tasks", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 如果有重命名的旧表,也删除
|
||||||
|
migrationBuilder.Sql("DROP TABLE IF EXISTS Tasks_Old;");
|
||||||
|
|
||||||
|
// 5. 重新启用外键检查
|
||||||
|
migrationBuilder.Sql("PRAGMA foreign_keys = ON;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Hua.Todo.Application.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Hua.Todo.Application.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -17,33 +17,6 @@ namespace Hua.Todo.Application.Migrations
|
|||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||||
|
|
||||||
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<bool>("AllowPersist")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<bool>("AllowSync")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(true);
|
|
||||||
|
|
||||||
b.Property<Guid>("UserId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("SecurityPolicies", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -78,82 +51,11 @@ namespace Hua.Todo.Application.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasDefaultValueSql("datetime('now')");
|
.HasDefaultValueSql("datetime('now')");
|
||||||
|
|
||||||
b.Property<Guid>("UserId")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ParentTaskId");
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.ToTable("Tasks");
|
||||||
|
|
||||||
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 =>
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
@@ -163,37 +65,13 @@ namespace Hua.Todo.Application.Migrations
|
|||||||
.HasForeignKey("ParentTaskId")
|
.HasForeignKey("ParentTaskId")
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
|
||||||
.WithMany("Tasks")
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("ParentTask");
|
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 =>
|
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("SubTasks");
|
b.Navigation("SubTasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Tasks");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ public class TaskRepository : ITaskRepository
|
|||||||
public async Task<List<TaskEntity>> GetAllAsync()
|
public async Task<List<TaskEntity>> GetAllAsync()
|
||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => t.UserId == TodoUserIds.LocalUserId)
|
|
||||||
.Include(t => t.SubTasks)
|
.Include(t => t.SubTasks)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
@@ -42,7 +41,7 @@ public class TaskRepository : ITaskRepository
|
|||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Include(t => t.SubTasks)
|
.Include(t => t.SubTasks)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
|
.FirstOrDefaultAsync(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -52,7 +51,7 @@ public class TaskRepository : ITaskRepository
|
|||||||
public async Task<List<TaskEntity>> GetActiveTasksAsync()
|
public async Task<List<TaskEntity>> GetActiveTasksAsync()
|
||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
|
.Where(t => !t.IsCompleted)
|
||||||
.OrderByDescending(t => t.CreatedAt)
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
@@ -64,7 +63,7 @@ public class TaskRepository : ITaskRepository
|
|||||||
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
|
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
|
||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
|
.Where(t => t.IsCompleted)
|
||||||
.OrderByDescending(t => t.UpdatedAt)
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
@@ -76,7 +75,6 @@ public class TaskRepository : ITaskRepository
|
|||||||
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
|
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
|
||||||
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
|
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
|
||||||
{
|
{
|
||||||
taskEntity.UserId = TodoUserIds.LocalUserId;
|
|
||||||
_context.Tasks.Add(taskEntity);
|
_context.Tasks.Add(taskEntity);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
return taskEntity;
|
return taskEntity;
|
||||||
@@ -102,7 +100,7 @@ public class TaskRepository : ITaskRepository
|
|||||||
/// <returns>表示删除操作的任务。</returns>
|
/// <returns>表示删除操作的任务。</returns>
|
||||||
public async Task DeleteAsync(int id)
|
public async Task DeleteAsync(int 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)
|
if (task != null)
|
||||||
{
|
{
|
||||||
_context.Tasks.Remove(task);
|
_context.Tasks.Remove(task);
|
||||||
@@ -118,7 +116,7 @@ public class TaskRepository : ITaskRepository
|
|||||||
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
|
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
|
||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
.OrderByDescending(t => t.CreatedAt)
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,9 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="Hua.Todo.Avalonia.App"
|
x:Class="Hua.Todo.Avalonia.App"
|
||||||
xmlns:local="using:Hua.Todo.Avalonia"
|
xmlns:local="using:Hua.Todo.Avalonia"
|
||||||
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
|
|
||||||
x:DataType="vm:AppTrayViewModel"
|
|
||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Default">
|
||||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
|
||||||
<TrayIcon.Icons>
|
|
||||||
<TrayIcons>
|
|
||||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
|
||||||
ToolTipText="{Binding TrayTooltipText}"
|
|
||||||
Command="{Binding ShowMainWindowCommand}">
|
|
||||||
<TrayIcon.Menu>
|
|
||||||
<NativeMenu>
|
|
||||||
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
|
|
||||||
<NativeMenuItemSeparator />
|
|
||||||
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
|
|
||||||
</NativeMenu>
|
|
||||||
</TrayIcon.Menu>
|
|
||||||
</TrayIcon>
|
|
||||||
</TrayIcons>
|
|
||||||
</TrayIcon.Icons>
|
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Data.Core.Plugins;
|
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using AvaloniaWebView;
|
using AvaloniaWebView;
|
||||||
using Hua.Todo.Avalonia.Models;
|
using Hua.Todo.Avalonia.Models;
|
||||||
@@ -13,7 +13,6 @@ using Hua.Todo.Avalonia.Views;
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -98,9 +97,7 @@ public partial class App : global::Avalonia.Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
DisableAvaloniaDataAnnotationValidation();
|
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
|
||||||
|
|
||||||
_webServer = new EmbeddedWebServerService(_appSettings!);
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -132,11 +129,17 @@ public partial class App : global::Avalonia.Application
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
DataContext = new AppTrayViewModel(
|
// 只在支持的平台上初始化系统托盘
|
||||||
|
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
var trayViewModel = new AppTrayViewModel(
|
||||||
AppMetadata.GetTrayTooltipText(),
|
AppMetadata.GetTrayTooltipText(),
|
||||||
ShowMainWindow,
|
ShowMainWindow,
|
||||||
() => ExitApplication(desktop));
|
() => ExitApplication(desktop));
|
||||||
|
|
||||||
|
InitializeTrayIcon(trayViewModel);
|
||||||
|
}
|
||||||
|
|
||||||
_mainWindow = new MainWindow(_appSettings!, _webServer)
|
_mainWindow = new MainWindow(_appSettings!, _webServer)
|
||||||
{
|
{
|
||||||
Width = 450,
|
Width = 450,
|
||||||
@@ -185,6 +188,12 @@ public partial class App : global::Avalonia.Application
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||||
|
{
|
||||||
|
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
|
||||||
|
|
||||||
|
singleView.MainView = new MainView(_appSettings!, _webServer);
|
||||||
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
@@ -257,14 +266,43 @@ public partial class App : global::Avalonia.Application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisableAvaloniaDataAnnotationValidation()
|
private void InitializeTrayIcon(AppTrayViewModel trayViewModel)
|
||||||
{
|
{
|
||||||
var dataValidationPluginsToRemove =
|
try
|
||||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
{
|
||||||
|
var trayIcons = new TrayIcons();
|
||||||
|
|
||||||
foreach (var plugin in dataValidationPluginsToRemove)
|
var iconUri = new Uri("avares://Hua.Todo.Avalonia/icon.ico");
|
||||||
|
var icon = new WindowIcon(AssetLoader.Open(iconUri));
|
||||||
|
|
||||||
|
var trayIcon = new TrayIcon
|
||||||
{
|
{
|
||||||
BindingPlugins.DataValidators.Remove(plugin);
|
Icon = icon,
|
||||||
|
ToolTipText = trayViewModel.TrayTooltipText,
|
||||||
|
Command = trayViewModel.ShowMainWindowCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
var menu = new NativeMenu();
|
||||||
|
menu.Items.Add(new NativeMenuItem
|
||||||
|
{
|
||||||
|
Header = "显示主窗口",
|
||||||
|
Command = trayViewModel.ShowMainWindowCommand
|
||||||
|
});
|
||||||
|
menu.Items.Add(new NativeMenuItemSeparator());
|
||||||
|
menu.Items.Add(new NativeMenuItem
|
||||||
|
{
|
||||||
|
Header = "退出",
|
||||||
|
Command = trayViewModel.ExitApplicationCommand
|
||||||
|
});
|
||||||
|
|
||||||
|
trayIcon.Menu = menu;
|
||||||
|
trayIcons.Add(trayIcon);
|
||||||
|
|
||||||
|
TrayIcon.SetIcons(this, trayIcons);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[App] Tray icon initialization failed: {ex}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 172 KiB |
@@ -1,7 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||||
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('linux'))">net10.0</TargetFrameworks>
|
||||||
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">net10.0</TargetFrameworks>
|
||||||
|
<!-- 追加 Android 目标,作为未来的移动端主入口。 -->
|
||||||
|
<TargetFrameworks>$(TargetFrameworks);net10.0-android</TargetFrameworks>
|
||||||
|
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
@@ -11,30 +17,56 @@
|
|||||||
<ForceWebBuild>false</ForceWebBuild>
|
<ForceWebBuild>false</ForceWebBuild>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-android'">
|
||||||
|
<SupportedOSPlatformVersion>21.0</SupportedOSPlatformVersion>
|
||||||
|
<ApplicationId>com.hua.todo</ApplicationId>
|
||||||
|
<ApplicationTitle>代办</ApplicationTitle>
|
||||||
|
<AndroidApplication>true</AndroidApplication>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
|
||||||
|
<!-- 彻底解决 Java.Lang.NoClassDefFoundError:
|
||||||
|
在 .NET 10 Preview 环境中,Fast Deployment 往往无法正确处理动态加载的 AndroidX 资源。
|
||||||
|
通过禁用 Fast Deployment 并强制将所有程序集嵌入 APK,可以解决此问题。 -->
|
||||||
|
<AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
|
||||||
|
<AndroidEnableFastDeployment>False</AndroidEnableFastDeployment>
|
||||||
|
<EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Models\" />
|
<Folder Include="Models\" />
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="icon.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
<PackageReference Include="Avalonia" Version="11.3.13" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
<PackageReference Include="Avalonia.Android" Version="11.3.13" Condition="'$(TargetFramework)' == 'net10.0-android'" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.13" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.13" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.13" />
|
||||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||||
|
<!-- 增加 Android 下的 WebView 支持包,避免跨平台逻辑中断。 -->
|
||||||
|
<PackageReference Include="WebView.Avalonia.Android" Version="11.0.0.1" Condition="'$(TargetFramework)' == 'net10.0-android'" />
|
||||||
|
|
||||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.13">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
|
||||||
|
<!-- 为了修复 NoClassDefFoundError,我们需要显式确保核心库在 AOT 或 裁剪时被保留。 -->
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.AppCompat" Version="1.7.1.1" Condition="'$(TargetFramework)' == 'net10.0-android'" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="appsettings.json">
|
<Content Include="appsettings.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="icon.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ public class WebServerSettings
|
|||||||
|
|
||||||
[JsonPropertyName("ForEndUrl")]
|
[JsonPropertyName("ForEndUrl")]
|
||||||
public string ForEndUrl { get; set; } = "http://localhost:5174";
|
public string ForEndUrl { get; set; } = "http://localhost:5174";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。
|
||||||
|
/// 空字符串表示未配置云同步。
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("CloudSyncUrl")]
|
||||||
|
public string CloudSyncUrl { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class HotKeyDefaultSettings
|
public class HotKeyDefaultSettings
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.hua.todo">
|
||||||
|
<application android:allowBackup="true" android:icon="@drawable/icon" android:label="代办" android:supportsRtl="true">
|
||||||
|
</application>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Android.App;
|
||||||
|
using Android.Content.PM;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Android;
|
||||||
|
|
||||||
|
namespace Hua.Todo.Avalonia.Platforms.Android;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Avalonia Android 平台入口 Activity。
|
||||||
|
/// </summary>
|
||||||
|
[Activity(
|
||||||
|
Label = "代办",
|
||||||
|
Theme = "@style/AvaloniaMainActivityTheme",
|
||||||
|
Icon = "@drawable/icon",
|
||||||
|
MainLauncher = true,
|
||||||
|
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||||
|
public class MainActivity : AvaloniaMainActivity<App>
|
||||||
|
{
|
||||||
|
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||||
|
{
|
||||||
|
return base.CustomizeAppBuilder(builder)
|
||||||
|
.WithInterFont();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.WebView.Desktop;
|
using Avalonia.WebView.Desktop;
|
||||||
|
using Hua.Todo.Avalonia.Services;
|
||||||
|
using Hua.Todo.Avalonia.Models;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Hua.Todo.Avalonia;
|
namespace Hua.Todo.Avalonia;
|
||||||
@@ -10,8 +12,14 @@ sealed class Program
|
|||||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
// yet and stuff might break.
|
// yet and stuff might break.
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// 设置嵌入式 Web 服务器工厂
|
||||||
|
EmbeddedWebServerServiceFactory.SetFactory(appSettings => new EmbeddedWebServerService(appSettings));
|
||||||
|
|
||||||
|
BuildAvaloniaApp()
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
// Avalonia configuration, don't remove; also used by visual designer.
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user