From 7a4c516a2059357cd64c87b7978af2890c2d2e05 Mon Sep 17 00:00:00 2001 From: ShaoHua <345265198@qqcom> Date: Tue, 7 Apr 2026 03:34:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20CloudSync=20?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E8=83=BD=E5=8A=9B=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20Avalonia=20=E6=A1=8C=E9=9D=A2=E7=AB=AF=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:新增 CloudSync 认证/权限/端点/服务与 DTO - 数据:新增用户/会话/安全策略实体与 EF Core migrations - 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot - 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键 - 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置 - 文档:同步更新 README/docs 与协作规则 --- .gitignore | 2 + .trae/coordination/README.md | 18 + .trae/coordination/ownership.md | 13 + .trae/coordination/shared-files.md | 11 + .../rules/parallel_solo_conflict_avoidance.md | 99 ++++ .trae/rules/task_breakdown.md | 11 +- Directory.Build.targets | 8 + Hua.Todo.slnx | 5 +- README.md | 20 +- docs/manual/技术栈与模块.md | 5 +- docs/manual/版本记录.md | 10 +- docs/project/v1.2.0-tasks/00-任务总览.md | 16 +- .../01-Linux-Avalonia入口与WebView.md | 31 +- .../04-CloudSync-服务端基础能力.md | 108 +++- publish-linux.ps1 | 115 ++++ publish-windows.ps1 | 325 +++++++++++ publish.ps1 | 105 ++-- .../Auth/ClaimsPrincipalExtensions.cs | 53 ++ .../CloudSync/Auth/CloudClaims.cs | 18 + .../CloudSync/Auth/CloudPermissions.cs | 38 ++ .../Auth/DefaultRolePermissionMapper.cs | 43 ++ .../CloudSync/Auth/IRolePermissionMapper.cs | 15 + .../Auth/SessionAuthenticationDefaults.cs | 13 + .../Auth/SessionAuthenticationHandler.cs | 114 ++++ .../CloudSync/CloudSyncEndpointExtensions.cs | 194 +++++++ .../CloudSyncServiceCollectionExtensions.cs | 47 ++ .../CloudSync/Models/ApiErrorResponse.cs | 18 + .../CloudSync/Models/AuthDtos.cs | 87 +++ .../CloudSync/Models/CloudApiErrors.cs | 54 ++ .../CloudSync/Models/SecurityPolicyDtos.cs | 38 ++ .../CloudSync/Models/TaskSyncDtos.cs | 108 ++++ .../CloudSync/Services/CloudAuthService.cs | 188 ++++++ .../Services/CloudTaskSyncService.cs | 128 +++++ .../Services/SecurityPolicyService.cs | 69 +++ .../Data/TodoDbContext.cs | 57 ++ .../Hua.Todo.Application.csproj | 1 + ...72936_AddCloudSyncCoreEntities.Designer.cs | 198 +++++++ ...20260406172936_AddCloudSyncCoreEntities.cs | 136 +++++ ...4_AddAllowSyncToSecurityPolicy.Designer.cs | 203 +++++++ ...0406173734_AddAllowSyncToSecurityPolicy.cs | 29 + .../Migrations/TodoDbContextModelSnapshot.cs | 128 ++++- .../Repositories/TaskRepository.cs | 12 +- src/Hua.Todo.Avalonia/App.axaml | 33 ++ src/Hua.Todo.Avalonia/App.axaml.cs | 270 +++++++++ .../Assets/avalonia-logo.ico | Bin 0 -> 175875 bytes .../Hua.Todo.Avalonia.csproj | 64 +++ src/Hua.Todo.Avalonia/Models/AppSettings.cs | 42 ++ src/Hua.Todo.Avalonia/Models/HotKeyConfig.cs | 72 +++ src/Hua.Todo.Avalonia/Program.cs | 23 + src/Hua.Todo.Avalonia/Services/AppMetadata.cs | 60 ++ .../Services/EmbeddedWebServerService.cs | 208 +++++++ .../Services/GlobalHotKeyServiceFactory.cs | 25 + .../Services/HotKeySettingsService.cs | 130 +++++ .../Services/IEmbeddedWebServerService.cs | 29 + .../Services/IGlobalHotKeyService.cs | 36 ++ .../Services/NoopGlobalHotKeyService.cs | 29 + .../Platforms/WindowsGlobalHotKeyService.cs | 264 +++++++++ .../Platforms/WindowsKeyboardHandler.cs | 138 +++++ src/Hua.Todo.Avalonia/ViewLocator.cs | 37 ++ .../ViewModels/AppTrayViewModel.cs | 47 ++ .../ViewModels/MainWindowViewModel.cs | 6 + .../ViewModels/ViewModelBase.cs | 7 + src/Hua.Todo.Avalonia/Views/MainWindow.axaml | 21 + .../Views/MainWindow.axaml.cs | 106 ++++ src/Hua.Todo.Avalonia/app.manifest | 18 + src/Hua.Todo.Avalonia/appsettings.json | 14 + .../Entities/SecurityPolicyEntity.cs | 32 ++ src/Hua.Todo.Core/Entities/TaskEntity.cs | 11 + src/Hua.Todo.Core/Entities/TodoUserIds.cs | 13 + src/Hua.Todo.Core/Entities/UserEntity.cs | 33 ++ .../Entities/UserSessionEntity.cs | 38 ++ src/Hua.Todo.Host/Program.cs | 5 +- src/Hua.Todo.Maui/Hua.Todo.Maui.csproj | 35 +- .../{ => Resources/Images}/icon.jpg | Bin src/Hua.Todo.Maui/appsettings.json | 2 +- src/Hua.Todo.Maui/setup.iss | 4 +- src/Hua.Todo.Web/package.json | 3 +- src/Hua.Todo.Web/src/App.vue | 2 + src/Hua.Todo.Web/src/api/cloudClient.ts | 69 +++ src/Hua.Todo.Web/src/api/cloudSync.ts | 98 ++++ .../components/CloudSyncSettingsDialog.vue | 534 ++++++++++++++++++ src/Hua.Todo.Web/src/components/TaskItem.vue | 38 +- src/Hua.Todo.Web/src/components/TaskList.vue | 273 ++++++++- .../src/services/cloudSyncStorage.ts | 100 ++++ src/Hua.Todo.Web/vite.config.ts | 41 +- 85 files changed, 5774 insertions(+), 127 deletions(-) create mode 100644 .trae/coordination/README.md create mode 100644 .trae/coordination/ownership.md create mode 100644 .trae/coordination/shared-files.md create mode 100644 .trae/rules/parallel_solo_conflict_avoidance.md create mode 100644 Directory.Build.targets create mode 100644 publish-linux.ps1 create mode 100644 publish-windows.ps1 create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs create mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs create mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs create mode 100644 src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs create mode 100644 src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs create mode 100644 src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs create mode 100644 src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs create mode 100644 src/Hua.Todo.Avalonia/App.axaml create mode 100644 src/Hua.Todo.Avalonia/App.axaml.cs create mode 100644 src/Hua.Todo.Avalonia/Assets/avalonia-logo.ico create mode 100644 src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj create mode 100644 src/Hua.Todo.Avalonia/Models/AppSettings.cs create mode 100644 src/Hua.Todo.Avalonia/Models/HotKeyConfig.cs create mode 100644 src/Hua.Todo.Avalonia/Program.cs create mode 100644 src/Hua.Todo.Avalonia/Services/AppMetadata.cs create mode 100644 src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs create mode 100644 src/Hua.Todo.Avalonia/Services/GlobalHotKeyServiceFactory.cs create mode 100644 src/Hua.Todo.Avalonia/Services/HotKeySettingsService.cs create mode 100644 src/Hua.Todo.Avalonia/Services/IEmbeddedWebServerService.cs create mode 100644 src/Hua.Todo.Avalonia/Services/IGlobalHotKeyService.cs create mode 100644 src/Hua.Todo.Avalonia/Services/NoopGlobalHotKeyService.cs create mode 100644 src/Hua.Todo.Avalonia/Services/Platforms/WindowsGlobalHotKeyService.cs create mode 100644 src/Hua.Todo.Avalonia/Services/Platforms/WindowsKeyboardHandler.cs create mode 100644 src/Hua.Todo.Avalonia/ViewLocator.cs create mode 100644 src/Hua.Todo.Avalonia/ViewModels/AppTrayViewModel.cs create mode 100644 src/Hua.Todo.Avalonia/ViewModels/MainWindowViewModel.cs create mode 100644 src/Hua.Todo.Avalonia/ViewModels/ViewModelBase.cs create mode 100644 src/Hua.Todo.Avalonia/Views/MainWindow.axaml create mode 100644 src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs create mode 100644 src/Hua.Todo.Avalonia/app.manifest create mode 100644 src/Hua.Todo.Avalonia/appsettings.json create mode 100644 src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs create mode 100644 src/Hua.Todo.Core/Entities/TodoUserIds.cs create mode 100644 src/Hua.Todo.Core/Entities/UserEntity.cs create mode 100644 src/Hua.Todo.Core/Entities/UserSessionEntity.cs rename src/Hua.Todo.Maui/{ => Resources/Images}/icon.jpg (100%) create mode 100644 src/Hua.Todo.Web/src/api/cloudClient.ts create mode 100644 src/Hua.Todo.Web/src/api/cloudSync.ts create mode 100644 src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue create mode 100644 src/Hua.Todo.Web/src/services/cloudSyncStorage.ts diff --git a/.gitignore b/.gitignore index 12d6f65..4470b42 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,5 @@ FodyWeavers.xsd /Hua.Todo/Output /src/Hua.Todo.Maui/Output /src/Hua.Todo.Host/Hua.Todo.db +/src/Hua.Todo.Host/Hua.Todo.db-shm +/src/Hua.Todo.Host/Hua.Todo.db-wal diff --git a/.trae/coordination/README.md b/.trae/coordination/README.md new file mode 100644 index 0000000..a8d0ad4 --- /dev/null +++ b/.trae/coordination/README.md @@ -0,0 +1,18 @@ +# 协调目录(并行 solo 专用) + +该目录用于解决两类问题: + +- 文件冲突:多窗口并行时明确“谁是 Writer”,其他人不直接改同一文件 +- 编译中途状态:确保阶段性交付保持可编译(绿线),必要时通过隔离策略推进 + +## 目录约定 + +- `ownership.md`:文件/目录所有权登记(Writer 表) +- `shared-files.md`:本阶段共享文件清单(由 Integrator 维护) +- `handoff/`:非 Writer 提交的差异建议/交接说明(Integrator 负责落盘) +- `wip/`:编译中途状态说明(为什么隔离、隔离方式、收敛条件) + +## 使用规则 + +- 所有权与共享文件清单优先使用“仓库相对路径” +- 禁止记录或提交构建产物目录中的文件路径(如 `bin/`、`obj/`、`node_modules/`、`dist/` 等) diff --git a/.trae/coordination/ownership.md b/.trae/coordination/ownership.md new file mode 100644 index 0000000..278c564 --- /dev/null +++ b/.trae/coordination/ownership.md @@ -0,0 +1,13 @@ +# 文件/目录所有权(Writer)登记 + +规则: + +- 同一时段内,同一个文件只能有一个 Writer +- 非 Writer 不编辑该文件;需要修改时,提交到 `.trae\coordination\handoff\` 由 Writer/Integrator 落盘 +- 路径建议使用仓库相对路径;每次扩大修改范围,先更新登记再改代码 + +## 当前所有权 + +| Path(仓库相对路径) | Writer | 任务/窗口标识 | 备注 | +|---|---|---|---| +| `src\\` | `` | `` | `共享:是/否` | diff --git a/.trae/coordination/shared-files.md b/.trae/coordination/shared-files.md new file mode 100644 index 0000000..9f38fb7 --- /dev/null +++ b/.trae/coordination/shared-files.md @@ -0,0 +1,11 @@ +# 本阶段共享文件清单(Integrator 维护) + +规则: + +- 本文件只由 Integrator 修改,避免反复冲突 +- 清单内每条必须是“仓库相对路径”,并说明为什么共享(入口/协议/配置/依赖锁等) +- 所有共享文件必须同时出现在各自任务的 Touch List 中,并标注 Writer 为 Integrator + +## 共享文件 + +- `src\\`:`` diff --git a/.trae/rules/parallel_solo_conflict_avoidance.md b/.trae/rules/parallel_solo_conflict_avoidance.md new file mode 100644 index 0000000..ad64aac --- /dev/null +++ b/.trae/rules/parallel_solo_conflict_avoidance.md @@ -0,0 +1,99 @@ +--- +alwaysApply: false +description: +--- +# 并行 solo 窗口冲突规约(必须遵守) + +## 适用范围 + +- 当同一个版本/需求被拆分为多个并行任务,并由多个 solo 窗口同时推进时适用。 +- 目标是同时降低两类风险: + - **文件冲突**:多人同时改同一文件/相邻行导致冲突。 + - **编译区间冲突**:A 窗口引入的未完成变更破坏编译,阻塞 B 窗口集成与验证。 + +## 核心原则 + +- **先声明后修改**:任何代码改动前,先在任务文档中声明“触碰文件清单(Touch List)”与“共享文件策略”。 +- **文件所有权唯一**:同一时段内,一个文件只能被一个窗口作为“写入者(Writer)”修改。 +- **共享文件单点修改**:涉及高耦合/共享入口的改动,集中到一个“集成窗口(Integrator)”完成,其他窗口只做准备工作(新文件/独立模块/文档/测试)。 +- **绿线优先(可编译)**:任何可落盘、可合入的变更必须保持可编译;临时状态必须通过“隔离手段”而不是破坏编译来实现。 + +## Touch List(触碰文件清单) + +- 每个并行任务 md 必须在开头包含一个明确的 Touch List,至少包含: + - 新增/修改/删除的文件路径(精确到文件,必须写“准确目录”) + - 预期修改类型(新增/小改/重构/接口变更/配置变更) + - 是否为共享文件(是/否) +- Touch List 必须保持可检索与可更新:变更范围扩大时,必须先更新 Touch List 再改代码。 + +### 目录书写要求(必须遵守) + +- Touch List 内每一条必须使用以下两种格式之一: + - **仓库相对路径(推荐)**:`src\\` + - **绝对路径(可选)**:`\src\\`(`` 为本机仓库根目录) +- Touch List 禁止包含构建产物与临时目录中的文件(这些文件不应被手工修改,且极易产生冲突),包括但不限于: + - `**\bin\**`、`**\obj\**` + - `**\node_modules\**` + - `**\.vite\**`、`**\dist\**` + +### Touch List 模板(复制即可用) + +- Touch List: + - `src\\`(共享:否|Writer:本窗口) + - `src\\`(共享:是|Writer:<窗口名>) + - `docs\`(共享:是/否|Writer:<窗口名>) + - `.trae\`(共享:是|Writer:<窗口名>) + +## 文件所有权与共享文件策略 + +- **默认规则**:Touch List 中标记为“共享文件”的条目,必须指定唯一 Writer。 +- **Writer 约束**: + - 非 Writer 窗口不得编辑该共享文件(包括格式化、重排 import、无关重构)。 + - 需要对共享文件提出修改时,非 Writer 只能提供“差异建议”(文字说明/伪代码/小片段)交给 Writer 落盘。 +- **共享文件判定(满足其一即为共享)**: + - 项目入口/启动逻辑、依赖注入注册、全局路由/导航、公共配置、公共协议与 DTO、公共组件/样式、跨模块公共工具 + - 解决方案/项目文件(如 `.sln`、`.csproj`)、锁文件、全局配置文件(如 `appsettings*`、构建脚本) + +## 协调目录(必须遵守) + +- 为了让“文件冲突”和“编译中途状态”可操作、可对齐,仓库内必须固定保留一个专用协调目录: + - `.trae\coordination\` +- 该目录只用于协作对齐,不承载业务实现代码;多人可在不同文件中写入,避免互相踩踏。 +- 并行推进时必须使用该目录中的文件记录“谁在改什么”和“中途状态怎么保证不破坏编译”: + - `.trae\coordination\ownership.md`:文件/目录所有权(Writer)登记表 + - `.trae\coordination\shared-files.md`:本阶段共享文件清单(只有 Integrator 维护) + - `.trae\coordination\handoff\`:非 Writer 提交的差异建议/交接说明(Integrator 落盘) + - `.trae\coordination\wip\`:编译中途状态说明(为什么需要隔离、如何保证绿线、何时收敛) + +## 目录分区与低冲突写法 + +- 优先通过“新增文件”完成并行开发,减少在同一文件内的交错修改。 +- 需要扩展既有逻辑时,优先选择低冲突策略: + - C#:新增类/partial 文件、扩展方法、接口实现分文件、平台目录分离 + - TypeScript/Vue:新增模块/组件文件,避免在同一大文件内做多处改动 +- 禁止在非必要情况下对共享文件做纯格式化、纯重排或无收益重构(这些改动高度易冲突且难以 review)。 + +## 编译绿线(避免编译区间冲突) + +- **不得提交/合入破坏编译的变更**:包括缺失类型、未实现接口、引用不存在、配置缺项导致启动失败等。 +- **允许的临时隔离手段(按优先级)**: + 1. 新功能先放在新文件/新类中,不在入口路径上启用 + 2. 通过显式开关控制启用(配置/运行时开关),默认关闭 + 3. 通过依赖注入分支注册或特性开关隔离,默认不触发 +- 当必须进行接口演进时,采用“双写/兼容期”策略: + - 先新增(保持旧接口可用)→ 再迁移调用方 → 最后清理旧接口 + +## 合入顺序与集成职责 + +- 每个并行阶段必须明确一个集成窗口(Integrator),负责: + - 处理共享文件的实际落盘与冲突消解 + - 保持主干/集成分支持续可编译、可运行 +- 其他窗口提交的成果应尽量以“新增文件 + 最小修改点”的方式交付,降低集成成本。 + +## 最小检查清单 + +1. [ ] 每个任务 md 是否已写 Touch List(精确到文件)? +2. [ ] Touch List 中的共享文件是否指定了唯一 Writer? +3. [ ] 是否避免了对共享文件的无意义格式化/重排? +4. [ ] 当前改动是否保持可编译(绿线)? +5. [ ] 若涉及接口演进,是否采用兼容期策略而非一次性破坏式变更? diff --git a/.trae/rules/task_breakdown.md b/.trae/rules/task_breakdown.md index f5540a6..38d701e 100644 --- a/.trae/rules/task_breakdown.md +++ b/.trae/rules/task_breakdown.md @@ -1,8 +1,6 @@ --- alwaysApply: false -description: 强制任务拆分输出规范:阅读完所有文档后,先产出拆分后的任务 Markdown,并按可并行执行拆分为多个带序号的 md 文件,统一放入 docs/project 下的新建文件夹。 --- - # 任务拆分输出规范(必须遵守) ## 适用时机 @@ -23,6 +21,13 @@ description: 强制任务拆分输出规范:阅读完所有文档后,先产 - 前置条件(依赖哪些结论/接口/文档) - 验收标准(怎么判断完成,包含可执行的验证点) - 风险与回滚(如有) +- **子任务完成后的标记要求**: + - 当任一子任务(例如 `01-*`/`02-*`/`03-*`)完成实现后,必须在对应版本的 `00-任务总览.md` 中同步标注“已完成”。 + - 同时必须维护一张“待验证表”(可用 Markdown 表格),对每个子任务给出“待验证/已验证”状态,避免实现完成但验收未闭环。 +- **并行冲突规避要求**:当任务会被分发到多个 solo 窗口并行推进时,每个任务文件必须额外包含: + - 触碰文件清单(Touch List,精确到文件) + - 共享文件策略(哪些是共享文件、唯一 Writer 是谁、如何与集成窗口对接) + - 编译绿线策略(如何确保阶段性交付不破坏编译) ## 推荐结构(模板) @@ -41,3 +46,5 @@ description: 强制任务拆分输出规范:阅读完所有文档后,先产 3. [ ] 是否产出 `00-总览.md`(或等价总览文件)? 4. [ ] 是否将可并行任务拆分为不同 md 文件? 5. [ ] 是否所有 md 文件都带有连续序号? +6. [ ] 并行任务是否为每个任务文件补充了 Touch List/共享文件策略/编译绿线策略? +7. [ ] 子任务完成后,是否在对应版本的 `00-任务总览.md` 标注“已完成”,并在“待验证表”里更新状态? diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..705c3e5 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + false + true + false + + diff --git a/Hua.Todo.slnx b/Hua.Todo.slnx index c1f8009..b3eb81d 100644 --- a/Hua.Todo.slnx +++ b/Hua.Todo.slnx @@ -6,9 +6,8 @@ - - - + + diff --git a/README.md b/README.md index 278081f..0cc4fae 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # Hua.Todo 跨平台代办管理应用 -一个基于 MAUI + WebView 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。 +一个基于 WebView 容器(MAUI / Avalonia)+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。 ## 🚀 功能特点 ### 核心功能 -- **跨平台支持**:基于 MAUI + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览) +- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览) - **任务管理**:支持创建、编辑、删除、完成状态切换 - **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分 - **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成) - **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用 - **HTTP API 通信**:前后端通过 RESTful API 进行数据交互 +- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示) ## 📦 安装与使用 @@ -46,6 +47,19 @@ npm run dev ``` 前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173` +### Windows 交付产物(安装包) + +- 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj` 的 ``;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows//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/` + ### 使用说明 - **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮 - **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成) @@ -57,6 +71,7 @@ npm run dev ### 项目结构 ``` Hua.Todo/ +├── pack/ # 打包与交付产物(Linux/安装包等) ├── docs/ # 文档目录 │ ├── manual/ # 用户/开发者手册 │ └── project/ # 项目进度/需求文档 @@ -66,6 +81,7 @@ Hua.Todo/ │ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel) │ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite) │ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS) +│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux) │ └── Hua.Todo.slnx # 解决方案文件 ├── .gitignore # Git 忽略文件 └── README.md # 项目说明文档 diff --git a/docs/manual/技术栈与模块.md b/docs/manual/技术栈与模块.md index e6561f2..3e6ee57 100644 --- a/docs/manual/技术栈与模块.md +++ b/docs/manual/技术栈与模块.md @@ -5,7 +5,7 @@ ### 后端技术栈 - **开发语言**:C# 10 - **框架**:.NET 10 -- **UI 框架**:MAUI (Multi-platform App UI) +- **UI 框架**:MAUI(移动端/部分桌面) + Avalonia(桌面端) - **Web 服务器**:Kestrel (ASP.NET Core 内置) - **API 框架**:ASP.NET Core Web API - **数据访问**:Entity Framework Core @@ -37,3 +37,6 @@ ### Hua.Todo.Maui 跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。 + +### Hua.Todo.Avalonia +桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。 diff --git a/docs/manual/版本记录.md b/docs/manual/版本记录.md index 7f2b355..88a9ac0 100644 --- a/docs/manual/版本记录.md +++ b/docs/manual/版本记录.md @@ -9,6 +9,14 @@ - v1.1.0:MAUI + WebView 跨平台版本 - v1.2.0 (规划中):Linux 支持与增强功能 +### v1.2.0(开发中,2026-04-07) + +- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。 +- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示)。 +- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`。 +- **Windows 发布打包修复**:Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。 +- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。 + ### v1.1.1 (2026-04-06) - **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。 @@ -29,8 +37,8 @@ ### v1.2.0 规划内容 (即将推出) - **Linux 官方支持**:正式适配 Linux 平台。 +- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpak(manifest/desktop entry/AppStream)基础结构。 - **关键词检索**:支持按任务标题关键词搜索。 - **标签系统**:引入多标签支持,提升任务组织效率。 - **暗色模式**:全平台适配暗色/深色主题。 - **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。 - diff --git a/docs/project/v1.2.0-tasks/00-任务总览.md b/docs/project/v1.2.0-tasks/00-任务总览.md index 2cce752..6dda33e 100644 --- a/docs/project/v1.2.0-tasks/00-任务总览.md +++ b/docs/project/v1.2.0-tasks/00-任务总览.md @@ -19,9 +19,10 @@ - **Linux 入口线**:`01-*` + `02-*` - 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议 - - 02:Linux 打包/交付产物(优先自包含:AppImage/Flatpak 之一;补充 .deb/.tar.gz) + - 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1` 与 `pack/linux/`) - **Search 线**:`03-*` - 主要在前端完成;与云同步/平台入口基本无耦合,可并行 + - 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感) - **云同步线**:`04-*` + `05-*` + `06-*` - 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离) - 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务) @@ -29,9 +30,20 @@ - **文档与验收线**:`07-*` - 对齐文档与现实现状/接口;补齐验收步骤与自测清单 +## 待验证表 + +| 子任务 | 实现状态 | 验证状态 | 备注 | +|---|---|---|---| +| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 | +| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 | +| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 | +| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 | +| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增“云同步设置”弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 | +| 06 - 安全与落盘策略 | 未标注 | 待验证 | | +| 07 - 文档与验收 | 未标注 | 待验证 | | + ## 交付判定(v1.2.0 Done Definition) - Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物 - Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰) - 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发“是否允许落盘”;客户端在禁止落盘时不产生本地持久化 - diff --git a/docs/project/v1.2.0-tasks/01-Linux-Avalonia入口与WebView.md b/docs/project/v1.2.0-tasks/01-Linux-Avalonia入口与WebView.md index 15da2ca..9124b1b 100644 --- a/docs/project/v1.2.0-tasks/01-Linux-Avalonia入口与WebView.md +++ b/docs/project/v1.2.0-tasks/01-Linux-Avalonia入口与WebView.md @@ -39,6 +39,36 @@ - 开发态:Avalonia WebView 指向前端 dev server(例如 `http://localhost:5173`)或指向本地 WebServer - 生产态:加载随应用交付的静态资源(`wwwroot`)并确保 SPA fallback 生效(访问非 `/assets/*` 的路由能回到 `index.html`) +## 实现落地(已完成) + +### 1) 项目结构 + +- 新增 Linux 桌面端入口项目:`src/Hua.Todo.Avalonia` +- 入口类型:Avalonia Desktop App(ClassicDesktopLifetime) + +### 2) WebView 方案选型(已落定) + +- 采用:`WebView.Avalonia` + `WebView.Avalonia.Desktop` + - Windows:依赖 WebView2 Runtime + - Linux:依赖 GTK + WebKitGTK(运行环境缺失时 WebView 会初始化失败) + +### 3) 资源加载策略(与 MAUI 对齐) + +- 生产态(默认):内嵌 WebServer 托管 `wwwroot` 静态资源,WebView 指向 `HostUrl` +- 开发态:将 `IsUsingStatic=false`,WebView 指向 `ForEndUrl`(例如 Vite dev server) + +### 4) 前端契约对齐(与 MAUI 一致) + +- 在 WebView 导航完成后注入: + - `window.__API_BASE_URL__ = "${HostUrl}/api"` + - `window.mauiInterop`(事件名/字段名与现有前端保持一致) + +### 5) 本地 API 与数据库初始化 + +- 内嵌 WebServer 使用 Kestrel 启动并注册动态 API 与 Controllers +- 启动时执行数据库迁移(Migrate),并设置 SQLite WAL 模式以降低锁冲突风险 +- 默认 SQLite 路径使用用户目录(`LocalApplicationData/Hua.Todo/Hua.Todo.db`),避免安装目录无写权限导致启动失败 + ## 实施步骤(建议) 1. 新建 Avalonia 宿主项目 @@ -61,4 +91,3 @@ - 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用) - 页面路由/刷新不会出现 404(SPA fallback 生效) - 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位) - diff --git a/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md b/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md index c442cd6..2d3e23d 100644 --- a/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md +++ b/docs/project/v1.2.0-tasks/04-CloudSync-服务端基础能力.md @@ -21,13 +21,102 @@ ## 接口契约(需要在实现前先写清楚) -建议输出一份“云同步 API 契约”并固化到 docs(便于客户端对接)。至少包含: -- 登录:`POST /auth/login`(或等效) -- 获取用户任务:`GET /tasks`(或等效,支持增量/版本号是加分项,但 v1.2.0 可先全量) -- 上传/同步:`POST /sync`(或按任务粒度的增删改接口) -- 获取安全配置:`GET /security/policy` - - 返回字段至少包含:`allowPersist`(是否允许落盘) - - 可扩展:`deviceTrust`、`sessionTtl`、`requireSecondFactorFor` 等 +v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结字段名/错误码)。 + +### 通用约定 + +- Base URL:由客户端配置(例如 `https://{host}:{port}`) +- 认证:`Authorization: Bearer {accessToken}` +- 错误响应(统一结构): + +```json +{ "code": "ERROR_CODE", "message": "Human readable message." } +``` + +### 错误码 + +- `UNAUTHORIZED`:未登录或会话失效 +- `FORBIDDEN`:权限不足(RBAC / 策略拒绝) +- `SECOND_FACTOR_REQUIRED`:需要二次认证(step-up) +- `BAD_REQUEST`:请求参数不合法 +- `NOT_FOUND`:资源不存在 + +### Auth + +- 初始化管理员(仅当系统尚无云用户时允许一次): + - `POST /auth/bootstrap` + - Body:`{ "userName": "admin", "password": "..." }` + - 200:成功;403:已初始化或不允许 + +- 登录: + - `POST /auth/login` + - Body:`{ "userName": "admin", "password": "..." }` + - 200:`{ accessToken, expiresAtUtc, userId, role, permissions[] }` + +- 二次认证(step-up,v1.2.0 最小实现为“复用登录口令进行再认证”): + - `POST /auth/step-up` + - Header:`Authorization: Bearer ...` + - Body:`{ "password": "..." }` + - 200:`{ stepUpExpiresAtUtc }` + +### Tasks + +- 获取当前用户任务全量: + - `GET /tasks` + - Header:`Authorization: Bearer ...` + - 权限:`tasks:read` + - 200:`CloudTaskItem[]` + +### Sync + +- 上传/同步(增删改后返回最新全量): + - `POST /sync` + - Header:`Authorization: Bearer ...` + - 权限:`sync:write` + - 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`) + - Body: + +```json +{ + "upserts": [ + { "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null }, + { "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null } + ], + "deletes": [2, 3] +} +``` + + - 200: + +```json +{ + "serverTimeUtc": "2026-04-06T17:39:30.0281279Z", + "tasks": [ /* CloudTaskItem[] */ ] +} +``` + +### Security Policy + +- 获取安全策略(服务端下发): + - `GET /security/policy` + - Header:`Authorization: Bearer ...` + - 权限:`policy:read` + - 200: + +```json +{ + "allowPersist": true, + "allowSync": true, + "requireSecondFactorFor": ["sync:write", "policy:write"] +} +``` + +- 更新安全策略(高风险操作): + - `PUT /security/policy` + - Header:`Authorization: Bearer ...` + - 权限:`policy:write` + - 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`) + - Body:`{ "allowPersist": false, "allowSync": false }` ## 关键实现点(建议) @@ -49,3 +138,8 @@ - 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别) - 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为 +## 当前实现说明(v1.2.0) + +- 数据隔离:`Tasks` 表新增 `UserId` 外键,云端 API 的所有读写按当前会话用户隔离;本地模式使用固定的 `local` 用户 ID(不影响既有 Dynamic API)。 +- RBAC:内置 `admin / user / readonly / nosync` 角色与权限映射;并叠加 `SecurityPolicies.AllowSync` 作为“是否允许同步写入”的策略开关。 +- 二次认证:通过 `POST /auth/step-up` 将会话提升到 step-up 状态;`POST /sync` 与 `PUT /security/policy` 会强制要求 step-up。 diff --git a/publish-linux.ps1 b/publish-linux.ps1 new file mode 100644 index 0000000..f538bf0 --- /dev/null +++ b/publish-linux.ps1 @@ -0,0 +1,115 @@ +<# + Hua.Todo Linux 发布脚本(产出 .tar.gz) + + 目标: + - 为 Linux 提供可分发的目录产物(dotnet publish 输出) + - 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证) + + 约束: + - Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*) + - 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链 +#> + +param( + [ValidateSet("linux-x64", "linux-arm64")] + [string]$RuntimeIdentifier = "linux-x64", + + [switch]$SelfContained, + + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$ScriptPath = $PSScriptRoot + +function Get-FirstExistingFilePath { + param( + [Parameter(Mandatory = $true)] + [string[]]$Candidates + ) + + foreach ($candidate in $Candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $null +} + +function Read-ProjectVersion { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectFile + ) + + $currentVersion = "0.0.0" + [xml]$csproj = Get-Content $ProjectFile -Raw + + $versionNode = $csproj.SelectSingleNode("//Version") + if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { + return $versionNode.InnerText.Trim() + } + + $versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion") + if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { + return $versionNode.InnerText.Trim() + } + + return $currentVersion +} + +$candidateProjects = @( + (Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"), + (Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj") +) + +$ProjectFile = Get-FirstExistingFilePath -Candidates $candidateProjects +if ($null -eq $ProjectFile) { + $candidatesText = ($candidateProjects | ForEach-Object { " - $_" }) -join "`n" + Write-Error @" +未找到 Avalonia Linux 入口项目(无法生成 Linux 交付产物)。 + +请先完成 docs/project/v1.2.0-tasks/01-Linux-Avalonia入口与WebView.md 对应实现,并确保下列路径之一存在: +$candidatesText +"@ + exit 1 +} + +$version = Read-ProjectVersion -ProjectFile $ProjectFile +$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier" +$publishDir = Join-Path $artifactRoot "publish" + +if (Test-Path $artifactRoot) { + Remove-Item -Recurse -Force $artifactRoot +} +New-Item -ItemType Directory -Path $publishDir | Out-Null + +$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" } + +Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan + +dotnet publish $ProjectFile ` + -c $Configuration ` + -r $RuntimeIdentifier ` + --self-contained $selfContainedValue ` + -o $publishDir + +if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet publish failed" + exit 1 +} + +$packageName = "hua.todo-$version-$RuntimeIdentifier.tar.gz" +$packagePath = Join-Path $artifactRoot $packageName + +Write-Host "Creating tar.gz: $packageName" -ForegroundColor Cyan +tar -C $publishDir -czf $packagePath . + +if ($LASTEXITCODE -ne 0) { + Write-Error "tar.gz packaging failed" + exit 1 +} + +Write-Host "Linux artifact created: $packagePath" -ForegroundColor Green diff --git a/publish-windows.ps1 b/publish-windows.ps1 new file mode 100644 index 0000000..c9c17d2 --- /dev/null +++ b/publish-windows.ps1 @@ -0,0 +1,325 @@ +param( + [string]$TargetFramework = "net10.0-windows10.0.19041.0", + + [string]$RuntimeIdentifier = "win-x64", + + [string]$Configuration = "Release", + + [switch]$SkipInnoSetup, + + [switch]$SkipVersionBump, + + [string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)) +) + +$ErrorActionPreference = "Stop" + +$ScriptPath = $PSScriptRoot +$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui" +$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj" +$SetupScript = Join-Path $ProjectDir "setup.iss" +$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile) + +function Read-ProjectVersion { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectFile + ) + + $currentVersion = "0.0.0" + [xml]$csproj = Get-Content $ProjectFile -Raw + + $versionNode = $csproj.SelectSingleNode("//Version") + if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { + return $versionNode.InnerText.Trim() + } + + $versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion") + if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { + return $versionNode.InnerText.Trim() + } + + return $currentVersion +} + +function Read-InnoSetupAppName { + param( + [Parameter(Mandatory = $true)] + [string]$SetupScript + ) + + if (!(Test-Path $SetupScript)) { + return $null + } + + $content = Get-Content $SetupScript -Raw + $match = [regex]::Match($content, '#define\s+MyAppName\s+"([^"]+)"') + if ($match.Success) { + return $match.Groups[1].Value.Trim() + } + + return $null +} + +# Read OutputBaseFilename from setup.iss so we can determine the exact installer file path after ISCC runs. +# The value may contain Inno preprocessor macros like {#MyAppName}/{#MyAppVersion}. +function Read-InnoSetupOutputBaseFilename { + param( + [Parameter(Mandatory = $true)] + [string]$SetupScript + ) + + if (!(Test-Path $SetupScript)) { + return $null + } + + $content = Get-Content $SetupScript -Raw + $match = [regex]::Match($content, '^\s*OutputBaseFilename\s*=\s*(.+?)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline) + if ($match.Success) { + return $match.Groups[1].Value.Trim().Trim('"') + } + + return $null +} + +# Resolve the subset of Inno preprocessor macros used by this repo to a concrete file base name. +function Resolve-InnoSetupOutputBaseFilename { + param( + [Parameter(Mandatory = $true)] + [string]$Template, + + [string]$AppName, + + [string]$Version + ) + + if ([string]::IsNullOrWhiteSpace($Template)) { + return $null + } + + $resolved = $Template + if (![string]::IsNullOrWhiteSpace($AppName)) { + $resolved = $resolved.Replace("{#MyAppName}", $AppName) + } + if (![string]::IsNullOrWhiteSpace($Version)) { + $resolved = $resolved.Replace("{#MyAppVersion}", $Version) + } + + return $resolved.Trim() +} + +function Update-InnoSetupVersion { + param( + [Parameter(Mandatory = $true)] + [string]$SetupScript, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + if (!(Test-Path $SetupScript)) { + return + } + + $issContent = Get-Content $SetupScript + $versionFound = $false + for ($i = 0; $i -lt $issContent.Count; $i++) { + if ($issContent[$i] -like '#define MyAppVersion *') { + $issContent[$i] = '#define MyAppVersion "' + $Version + '"' + $versionFound = $true + break + } + } + + if ($versionFound) { + Set-Content $SetupScript -Value $issContent -Encoding UTF8 + } +} + +function Copy-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$Destination + ) + + if (Test-Path $Destination) { + Remove-Item -Recurse -Force $Destination + } + + New-Item -ItemType Directory -Path $Destination | Out-Null + Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force +} + +$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile +Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion + +Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan +# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。 +dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false +if ($LASTEXITCODE -ne 0) { + Write-Error "MAUI build failed" + exit 1 +} + +$publishDir = Join-Path $ProjectDir ("bin\{0}\{1}\{2}\publish" -f $Configuration, $TargetFramework, $RuntimeIdentifier) +if (!(Test-Path $publishDir)) { + $publishDir = (Get-ChildItem -Path (Join-Path $ProjectDir "bin\$Configuration") -Recurse -Directory -Filter publish -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName) +} +if (!(Test-Path $publishDir)) { + Write-Error "Publish directory not found" + exit 1 +} + +$publishAppSettings = Join-Path $publishDir "appsettings.json" +if (Test-Path $publishAppSettings) { + $appSettingsObj = Get-Content $publishAppSettings -Raw | ConvertFrom-Json + if ($null -eq $appSettingsObj.WebServer) { + $appSettingsObj | Add-Member -MemberType NoteProperty -Name "WebServer" -Value ([pscustomobject]@{}) + } + $appSettingsObj.WebServer.IsUsingStatic = $true + $appSettingsObj | ConvertTo-Json -Depth 32 | Set-Content -Path $publishAppSettings -Encoding UTF8 +} else { + Write-Error "Publish appsettings.json not found: $publishAppSettings" + exit 1 +} + +$desiredExeName = "$ProjectBaseName.exe" +$desiredExePath = Join-Path $publishDir $desiredExeName +if (!(Test-Path $desiredExePath)) { + $candidateExe = $null + $preferredCandidateNames = @( + "Hua.Todo.Maui.exe", + "Hua.Todo.exe" + ) + foreach ($name in $preferredCandidateNames) { + $candidatePath = Join-Path $publishDir $name + if (Test-Path $candidatePath) { + $candidateExe = Get-Item -Path $candidatePath + break + } + } + if ($null -eq $candidateExe) { + $exeFiles = Get-ChildItem -Path $publishDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike "*Setup*" } + $candidateExe = $exeFiles | Where-Object { Test-Path (Join-Path $publishDir ($_.BaseName + ".dll")) } | Select-Object -First 1 + if ($null -eq $candidateExe) { + $candidateExe = $exeFiles | Sort-Object Length -Descending | Select-Object -First 1 + } + } + if ($null -ne $candidateExe) { + Rename-Item -Path $candidateExe.FullName -NewName $desiredExeName -Force + } +} +if (!(Test-Path $desiredExePath)) { + Write-Error "Publish main executable not found: $desiredExeName" + exit 1 +} + +$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot" +$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html" +if (!(Test-Path $mauiIndexPath)) { + Write-Error "MAUI wwwroot not found: $mauiIndexPath" + exit 1 +} + +$publishWwwroot = Join-Path $publishDir "wwwroot" +if (Test-Path $publishWwwroot) { + Remove-Item -Recurse -Force $publishWwwroot +} +New-Item -ItemType Directory -Path $publishWwwroot | Out-Null +Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force + +$installerPath = $null +if (!$SkipInnoSetup.IsPresent) { + $ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + if (Test-Path $ISCC) { + $innoAppName = Read-InnoSetupAppName -SetupScript $SetupScript + $outputDir = Join-Path $ProjectDir "Output" + $outputBaseTemplate = Read-InnoSetupOutputBaseFilename -SetupScript $SetupScript + $resolvedOutputBase = Resolve-InnoSetupOutputBaseFilename -Template $outputBaseTemplate -AppName $innoAppName -Version $currentVersion + $expectedInstallerPath = $null + if (![string]::IsNullOrWhiteSpace($resolvedOutputBase)) { + $expectedInstallerPath = Join-Path $outputDir ("{0}.exe" -f $resolvedOutputBase) + } + + if (![string]::IsNullOrWhiteSpace($innoAppName)) { + # Prevent stale unversioned installer (Output\Hua.Todo.exe) from confusing local release artifacts. + $oldInstallerPath = Join-Path $outputDir ("{0}.exe" -f $innoAppName) + if (Test-Path $oldInstallerPath) { + Remove-Item -Path $oldInstallerPath -Force + } + } + + # ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly). + $maxAttempts = 3 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + $isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String) + $exitCode = $LASTEXITCODE + if ($exitCode -eq 0) { + break + } + + Write-Host $isccOutput + if ($attempt -lt $maxAttempts -and ($isccOutput -match 'Compile aborted|being used by another process|Sharing violation|process cannot access')) { + Start-Sleep -Seconds 2 + continue + } + + Write-Error "Packaging failed" + exit 1 + } + + if ($null -ne $expectedInstallerPath -and (Test-Path $expectedInstallerPath)) { + $installerPath = $expectedInstallerPath + } + + if ($null -eq $installerPath) { + if (Test-Path $outputDir) { + $installerPath = (Get-ChildItem -Path $outputDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName) + } + } + + Write-Host "Setup package created successfully!" -ForegroundColor Green + if ($null -ne $installerPath -and (Test-Path $installerPath)) { + Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green + } + } else { + Write-Error "Inno Setup compiler not found" + exit 1 + } +} + +if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) { + $publishArtifactDir = Join-Path $ArtifactsRoot "publish" + $installerArtifactDir = Join-Path $ArtifactsRoot "installer" + Copy-Directory -Source $publishDir -Destination $publishArtifactDir + + if ($null -ne $installerPath -and (Test-Path $installerPath)) { + if (Test-Path $installerArtifactDir) { + Remove-Item -Recurse -Force $installerArtifactDir + } + New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null + + $installerName = "hua.todo-$currentVersion-$RuntimeIdentifier-setup.exe" + Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force + } +} + +if (!$SkipVersionBump.IsPresent) { + $versionMatch = [regex]::Match($currentVersion, '^(?\d+)\.(?\d+)\.(?\d+)$') + if ($versionMatch.Success) { + $newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1) + + $content = Get-Content $ProjectFile -Raw + if ($content -match "[^<]*") { + $content = $content -replace "[^<]*", "$newVersion" + Set-Content $ProjectFile -Value $content -Encoding UTF8 + } else { + Write-Host "Skip version bump: node not found in csproj." -ForegroundColor Yellow + } + } else { + Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow + } +} diff --git a/publish.ps1 b/publish.ps1 index d6d85bd..9bc0148 100644 --- a/publish.ps1 +++ b/publish.ps1 @@ -1,62 +1,63 @@ +param( + [switch]$Windows, + + [switch]$Linux, + + [string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"), + + [switch]$LinuxSelfContained, + + [string]$Configuration = "Release", + + [switch]$SkipWindowsInnoSetup, + + [switch]$SkipWindowsVersionBump +) + $ErrorActionPreference = "Stop" $ScriptPath = $PSScriptRoot -$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui" -$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj" -$SetupScript = Join-Path $ProjectDir "setup.iss" -# Read version from project file -$currentVersion = "1.0.0" -[xml]$csproj = Get-Content $ProjectFile -Raw -$versionNode = $csproj.SelectSingleNode("//Version") -if ($null -ne $versionNode) { - $currentVersion = $versionNode.InnerText -} else { - $versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion") - if ($null -ne $versionNode) { - $currentVersion = $versionNode.InnerText +$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1" +$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1" + +$shouldPublishWindows = $Windows.IsPresent +$shouldPublishLinux = $Linux.IsPresent +if (!$shouldPublishWindows -and !$shouldPublishLinux) { + $shouldPublishWindows = $true + $shouldPublishLinux = $true +} + +if ($shouldPublishWindows) { + if (!(Test-Path $publishWindowsScript)) { + Write-Error "publish-windows.ps1 not found: $publishWindowsScript" + exit 1 + } + + & $publishWindowsScript ` + -Configuration $Configuration ` + -SkipInnoSetup:$SkipWindowsInnoSetup ` + -SkipVersionBump:$SkipWindowsVersionBump + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } } -# Update setup script version with current version before build -if (Test-Path $SetupScript) { - $issContent = Get-Content $SetupScript - $versionFound = $false - for ($i = 0; $i -lt $issContent.Count; $i++) { - if ($issContent[$i] -like '#define MyAppVersion *') { - $issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"' - $versionFound = $true - break +if ($shouldPublishLinux) { + if (!(Test-Path $publishLinuxScript)) { + Write-Error "publish-linux.ps1 not found: $publishLinuxScript" + exit 1 + } + + foreach ($rid in $LinuxRuntimes) { + & $publishLinuxScript ` + -RuntimeIdentifier $rid ` + -SelfContained:$LinuxSelfContained ` + -Configuration $Configuration + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } } - 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 -} - -$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" -if (Test-Path $ISCC) { - & $ISCC $SetupScript - if ($LASTEXITCODE -eq 0) { - Write-Host "Setup package created successfully!" -ForegroundColor Green - } else { - Write-Error "Packaging failed" - } -} else { - Write-Error "Inno Setup compiler not found" -} - -$versionParts = $currentVersion.Split(".") -$patch = [int]$versionParts[2] + 1 -$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch - -$content = Get-Content $ProjectFile -Raw -$content = $content -replace ".*", "$newVersion" -Set-Content $ProjectFile -Value $content diff --git a/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs b/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..4c89fc8 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; + +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 云同步鉴权相关的 扩展方法。 +/// +public static class ClaimsPrincipalExtensions +{ + /// + /// 获取当前用户 ID(若未登录则返回 null)。 + /// + /// 当前用户主体。 + /// 用户 ID。 + public static Guid? GetUserId(this ClaimsPrincipal user) + { + var value = user.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var id) ? id : null; + } + + /// + /// 获取当前会话 ID(若不可用则返回 null)。 + /// + /// 当前用户主体。 + /// 会话 ID。 + public static Guid? GetSessionId(this ClaimsPrincipal user) + { + var value = user.FindFirstValue(ClaimTypes.Sid); + return Guid.TryParse(value, out var id) ? id : null; + } + + /// + /// 判断是否具备指定权限。 + /// + /// 当前用户主体。 + /// 权限点。 + /// 是否具备权限。 + public static bool HasPermission(this ClaimsPrincipal user, string permission) + { + return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 判断是否已完成二次认证(step-up)。 + /// + /// 当前用户主体。 + /// 是否已完成二次认证。 + public static bool HasStepUp(this ClaimsPrincipal user) + { + return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase)); + } +} + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs b/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs new file mode 100644 index 0000000..0e7770e --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs @@ -0,0 +1,18 @@ +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 云同步鉴权使用的 Claim 名称约定。 +/// +public static class CloudClaims +{ + /// + /// 权限 Claim(可重复出现)。 + /// + public const string Permission = "perm"; + + /// + /// 二次认证状态 Claim。 + /// + public const string StepUp = "step_up"; +} + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs b/src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs new file mode 100644 index 0000000..7702fd9 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/CloudPermissions.cs @@ -0,0 +1,38 @@ +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 云同步权限点常量。 +/// +public static class CloudPermissions +{ + /// + /// 读取任务数据。 + /// + public const string TasksRead = "tasks:read"; + + /// + /// 写入任务数据。 + /// + public const string TasksWrite = "tasks:write"; + + /// + /// 允许执行同步写入(用于区分“允许同步/禁止同步”)。 + /// + public const string SyncWrite = "sync:write"; + + /// + /// 读取安全策略。 + /// + public const string PolicyRead = "policy:read"; + + /// + /// 修改安全策略(高风险)。 + /// + public const string PolicyWrite = "policy:write"; + + /// + /// 管理用户(创建/修改角色)。 + /// + public const string UsersManage = "users:manage"; +} + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs b/src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs new file mode 100644 index 0000000..1166c8a --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/DefaultRolePermissionMapper.cs @@ -0,0 +1,43 @@ +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 默认的角色-权限映射(内置最小集合)。 +/// +public class DefaultRolePermissionMapper : IRolePermissionMapper +{ + /// + public IReadOnlyList GetPermissions(string role) + { + return role switch + { + "admin" => new[] + { + CloudPermissions.TasksRead, + CloudPermissions.TasksWrite, + CloudPermissions.SyncWrite, + CloudPermissions.PolicyRead, + CloudPermissions.PolicyWrite, + CloudPermissions.UsersManage + }, + "readonly" => new[] + { + CloudPermissions.TasksRead, + CloudPermissions.PolicyRead + }, + "nosync" => new[] + { + CloudPermissions.TasksRead, + CloudPermissions.TasksWrite, + CloudPermissions.PolicyRead + }, + _ => new[] + { + CloudPermissions.TasksRead, + CloudPermissions.TasksWrite, + CloudPermissions.SyncWrite, + CloudPermissions.PolicyRead + } + }; + } +} + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs b/src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs new file mode 100644 index 0000000..03779c2 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/IRolePermissionMapper.cs @@ -0,0 +1,15 @@ +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 角色到权限集合的映射器(RBAC 最小落地)。 +/// +public interface IRolePermissionMapper +{ + /// + /// 获取指定角色对应的权限集合。 + /// + /// 角色名称。 + /// 权限列表。 + IReadOnlyList GetPermissions(string role); +} + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs b/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs new file mode 100644 index 0000000..1f57c2e --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationDefaults.cs @@ -0,0 +1,13 @@ +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 云同步会话鉴权默认配置。 +/// +public static class SessionAuthenticationDefaults +{ + /// + /// 会话鉴权 Scheme 名称。 + /// + public const string Scheme = "CloudSession"; +} + diff --git a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs b/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs new file mode 100644 index 0000000..4d8074b --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Hua.Todo.Application.Data; + +namespace Hua.Todo.Application.CloudSync.Auth; + +/// +/// 基于服务端会话表的 Bearer Token 鉴权处理器。 +/// +public class SessionAuthenticationHandler : AuthenticationHandler +{ + private readonly TodoDbContext _dbContext; + private readonly IRolePermissionMapper _rolePermissionMapper; + + /// + /// 创建 。 + /// + /// 鉴权 scheme 选项。 + /// 日志。 + /// 编码器。 + /// 数据库上下文。 + /// 角色权限映射器。 + public SessionAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + TodoDbContext dbContext, + IRolePermissionMapper rolePermissionMapper) + : base(options, logger, encoder) + { + _dbContext = dbContext; + _rolePermissionMapper = rolePermissionMapper; + } + + /// + protected override async Task HandleAuthenticateAsync() + { + var authorization = Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authorization)) + { + return AuthenticateResult.NoResult(); + } + + if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return AuthenticateResult.NoResult(); + } + + var token = authorization["Bearer ".Length..].Trim(); + if (!Guid.TryParse(token, out var sessionId)) + { + return AuthenticateResult.Fail("Invalid bearer token format."); + } + + var now = DateTime.UtcNow; + + var session = await _dbContext.UserSessions + .AsNoTracking() + .Include(s => s.User) + .FirstOrDefaultAsync(s => s.Id == sessionId, Context.RequestAborted); + + if (session == null || session.User == null) + { + return AuthenticateResult.Fail("Session not found."); + } + + if (session.ExpiresAtUtc <= now) + { + return AuthenticateResult.Fail("Session expired."); + } + + var user = session.User; + var permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList(); + + var allowSync = await _dbContext.SecurityPolicies + .AsNoTracking() + .Where(p => p.UserId == user.Id) + .Select(p => (bool?)p.AllowSync) + .FirstOrDefaultAsync(Context.RequestAborted); + + if (allowSync.HasValue && !allowSync.Value) + { + permissions.RemoveAll(p => string.Equals(p, CloudPermissions.SyncWrite, StringComparison.OrdinalIgnoreCase)); + } + + var claims = new List + { + new(ClaimTypes.Sid, session.Id.ToString()), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.UserName), + new(ClaimTypes.Role, user.Role) + }; + + foreach (var p in permissions) + { + 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 principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs new file mode 100644 index 0000000..bf1fcca --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs @@ -0,0 +1,194 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Hua.Todo.Application.CloudSync.Auth; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.CloudSync.Services; + +namespace Hua.Todo.Application.CloudSync; + +/// +/// 云同步服务端 API 路由注册扩展。 +/// +public static class CloudSyncEndpointExtensions +{ + /// + /// 映射云同步相关 API 端点。 + /// + /// Web 应用。 + /// Web 应用。 + public static WebApplication MapCloudSyncEndpoints(this WebApplication app) + { + var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth"); + auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); + auth.MapPost("/login", LoginAsync).AllowAnonymous(); + auth.MapPost("/step-up", StepUpAsync).AllowAnonymous(); + + var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); + tasks.MapGet("/", GetTasksAsync).AllowAnonymous(); + + var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync"); + sync.MapPost("/", SyncAsync).AllowAnonymous(); + + var security = app.MapGroup("/security").WithTags("CloudSync - Security"); + security.MapGet("/policy", GetPolicyAsync).AllowAnonymous(); + security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous(); + + return app; + } + + private static async Task BootstrapAdminAsync( + BootstrapAdminRequest request, + CloudAuthService authService, + CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) + { + return CloudApiErrors.BadRequest("UserName and Password are required."); + } + + var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken); + if (!ok) + { + return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input)."); + } + + return Results.Ok(); + } + + private static async Task LoginAsync( + LoginRequest request, + CloudAuthService authService, + CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password)) + { + return CloudApiErrors.BadRequest("UserName and Password are required."); + } + + var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken); + if (response == null) + { + return CloudApiErrors.Unauthorized("Invalid credentials."); + } + + return Results.Json(response); + } + + private static async Task StepUpAsync( + StepUpRequest request, + CloudAuthService authService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var sessionId = httpContext.User.GetSessionId(); + if (sessionId == null) + { + return CloudApiErrors.Unauthorized(); + } + + if (request == null || string.IsNullOrWhiteSpace(request.Password)) + { + return CloudApiErrors.BadRequest("Password is required."); + } + + var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken); + if (!expiresAt.HasValue) + { + return CloudApiErrors.Unauthorized("Invalid credentials or session expired."); + } + + return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value }); + } + + private static async Task GetTasksAsync( + CloudTaskSyncService taskService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var userId = httpContext.User.GetUserId(); + if (userId == null) + { + return CloudApiErrors.Unauthorized(); + } + + if (!httpContext.User.HasPermission(CloudPermissions.TasksRead)) + { + return CloudApiErrors.Forbidden(); + } + + var tasks = await taskService.GetTasksAsync(userId.Value, cancellationToken); + return Results.Json(tasks); + } + + private static async Task SyncAsync( + SyncRequest request, + CloudTaskSyncService taskService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var userId = httpContext.User.GetUserId(); + if (userId == null) + { + return CloudApiErrors.Unauthorized(); + } + + if (!httpContext.User.HasPermission(CloudPermissions.SyncWrite)) + { + return CloudApiErrors.Forbidden(); + } + + if (!httpContext.User.HasStepUp()) + { + return CloudApiErrors.SecondFactorRequired(); + } + + var response = await taskService.SyncAsync(userId.Value, request, cancellationToken); + return Results.Json(response); + } + + private static async Task GetPolicyAsync( + SecurityPolicyService policyService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var userId = httpContext.User.GetUserId(); + if (userId == null) + { + return CloudApiErrors.Unauthorized(); + } + + if (!httpContext.User.HasPermission(CloudPermissions.PolicyRead)) + { + return CloudApiErrors.Forbidden(); + } + + var policy = await policyService.GetPolicyAsync(userId.Value, cancellationToken); + return Results.Json(policy); + } + + private static async Task UpdatePolicyAsync( + UpdateSecurityPolicyRequest request, + SecurityPolicyService policyService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var userId = httpContext.User.GetUserId(); + if (userId == null) + { + return CloudApiErrors.Unauthorized(); + } + + if (!httpContext.User.HasPermission(CloudPermissions.PolicyWrite)) + { + return CloudApiErrors.Forbidden(); + } + + if (!httpContext.User.HasStepUp()) + { + return CloudApiErrors.SecondFactorRequired(); + } + + var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken); + return Results.Json(policy); + } +} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs new file mode 100644 index 0000000..5890846 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Hua.Todo.Application.CloudSync.Auth; +using Hua.Todo.Application.CloudSync.Services; +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.CloudSync; + +/// +/// 云同步服务端能力的依赖注入扩展。 +/// +public static class CloudSyncServiceCollectionExtensions +{ + /// + /// 注册云同步相关的认证、授权与业务服务。 + /// + /// 服务集合。 + /// 服务集合。 + public static IServiceCollection AddCloudSyncServer(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + + services.AddSingleton(); + services.AddScoped, PasswordHasher>(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddAuthentication(SessionAuthenticationDefaults.Scheme) + .AddScheme(SessionAuthenticationDefaults.Scheme, _ => { }); + + services.AddAuthorization(options => + { + options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead)); + options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite)); + options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite)); + options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead)); + options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite)); + options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage)); + }); + + return services; + } +} + diff --git a/src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs b/src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs new file mode 100644 index 0000000..c294067 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/ApiErrorResponse.cs @@ -0,0 +1,18 @@ +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 云同步 API 统一错误响应结构。 +/// +public class ApiErrorResponse +{ + /// + /// 错误码(用于客户端识别与提示)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 错误消息(用于调试或直接展示)。 + /// + public string Message { get; set; } = string.Empty; +} + diff --git a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs new file mode 100644 index 0000000..182be80 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs @@ -0,0 +1,87 @@ +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 登录请求。 +/// +public class LoginRequest +{ + /// + /// 用户名。 + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 密码。 + /// + public string Password { get; set; } = string.Empty; +} + +/// +/// 登录响应。 +/// +public class LoginResponse +{ + /// + /// Bearer Token(会话 ID)。 + /// + public string AccessToken { get; set; } = string.Empty; + + /// + /// 会话过期时间(UTC)。 + /// + public DateTime ExpiresAtUtc { get; set; } + + /// + /// 用户 ID。 + /// + public Guid UserId { get; set; } + + /// + /// 用户角色。 + /// + public string Role { get; set; } = string.Empty; + + /// + /// 用户权限列表。 + /// + public List Permissions { get; set; } = new(); +} + +/// +/// 初始化管理员账号请求(仅在系统尚无云用户时可用)。 +/// +public class BootstrapAdminRequest +{ + /// + /// 用户名。 + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 密码。 + /// + public string Password { get; set; } = string.Empty; +} + +/// +/// 二次认证(step-up)请求。 +/// +public class StepUpRequest +{ + /// + /// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。 + /// + public string Password { get; set; } = string.Empty; +} + +/// +/// 二次认证(step-up)响应。 +/// +public class StepUpResponse +{ + /// + /// 二次认证有效期截止(UTC)。 + /// + public DateTime StepUpExpiresAtUtc { get; set; } +} + diff --git a/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs b/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs new file mode 100644 index 0000000..5a43dba --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Http; + +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 云同步 API 错误响应生成器。 +/// +public static class CloudApiErrors +{ + /// + /// 生成标准错误响应。 + /// + /// HTTP 状态码。 + /// 业务错误码。 + /// 错误消息。 + /// 最小 API 结果。 + public static IResult Error(int statusCode, string code, string message) + { + return Results.Json( + new ApiErrorResponse { Code = code, Message = message }, + statusCode: statusCode); + } + + /// + /// 未认证。 + /// + public static IResult Unauthorized(string message = "Unauthorized.") + => Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message); + + /// + /// 权限不足。 + /// + public static IResult Forbidden(string message = "Forbidden.") + => Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message); + + /// + /// 需要二次认证(step-up)。 + /// + public static IResult SecondFactorRequired(string message = "Second factor required.") + => Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", message); + + /// + /// 请求非法。 + /// + public static IResult BadRequest(string message = "Bad request.") + => Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message); + + /// + /// 资源不存在。 + /// + public static IResult NotFound(string message = "Not found.") + => Error(StatusCodes.Status404NotFound, "NOT_FOUND", message); +} + diff --git a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs new file mode 100644 index 0000000..2e58e60 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs @@ -0,0 +1,38 @@ +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 安全策略下发 DTO。 +/// +public class SecurityPolicyDto +{ + /// + /// 是否允许落盘。 + /// + public bool AllowPersist { get; set; } + + /// + /// 是否允许同步写入。 + /// + public bool AllowSync { get; set; } + + /// + /// 需要二次认证的操作列表(操作码)。 + /// + public List RequireSecondFactorFor { get; set; } = new(); +} + +/// +/// 更新安全策略请求 DTO。 +/// +public class UpdateSecurityPolicyRequest +{ + /// + /// 是否允许落盘。 + /// + public bool AllowPersist { get; set; } + + /// + /// 是否允许同步写入。 + /// + public bool AllowSync { get; set; } +} diff --git a/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs new file mode 100644 index 0000000..373be01 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs @@ -0,0 +1,108 @@ +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 云同步任务条目。 +/// +public class CloudTaskItem +{ + /// + /// 任务 ID(服务端分配)。 + /// + public int Id { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 优先级。 + /// + public TaskPriority Priority { get; set; } + + /// + /// 是否完成。 + /// + public bool IsCompleted { get; set; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAtUtc { get; set; } + + /// + /// 更新时间(UTC)。 + /// + public DateTime UpdatedAtUtc { get; set; } + + /// + /// 父任务 ID(v1.2.0 同步可先不使用)。 + /// + public int? ParentTaskId { get; set; } +} + +/// +/// 同步请求(增改删)。 +/// +public class SyncRequest +{ + /// + /// 新增或更新的任务列表。 + /// + public List Upserts { get; set; } = new(); + + /// + /// 需要删除的任务 ID 列表。 + /// + public List Deletes { get; set; } = new(); +} + +/// +/// 任务 Upsert DTO。 +/// +public class CloudTaskUpsert +{ + /// + /// 任务 ID;为空表示新建。 + /// + public int? Id { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 优先级。 + /// + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + + /// + /// 是否完成。 + /// + public bool IsCompleted { get; set; } + + /// + /// 父任务 ID(可选)。 + /// + public int? ParentTaskId { get; set; } +} + +/// +/// 同步响应。 +/// +public class SyncResponse +{ + /// + /// 服务端时间(UTC)。 + /// + public DateTime ServerTimeUtc { get; set; } + + /// + /// 当前用户的任务全量。 + /// + public List Tasks { get; set; } = new(); +} + diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs new file mode 100644 index 0000000..ae15e93 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs @@ -0,0 +1,188 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Hua.Todo.Application.CloudSync.Auth; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.Data; +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.CloudSync.Services; + +/// +/// 云同步认证服务(登录、初始化管理员、二次认证)。 +/// +public class CloudAuthService +{ + private readonly TodoDbContext _dbContext; + private readonly IPasswordHasher _passwordHasher; + private readonly IRolePermissionMapper _rolePermissionMapper; + + /// + /// 创建 。 + /// + /// 数据库上下文。 + /// 密码哈希器。 + /// 角色权限映射器。 + public CloudAuthService( + TodoDbContext dbContext, + IPasswordHasher passwordHasher, + IRolePermissionMapper rolePermissionMapper) + { + _dbContext = dbContext; + _passwordHasher = passwordHasher; + _rolePermissionMapper = rolePermissionMapper; + } + + /// + /// 初始化系统管理员账号(仅在系统尚无云用户时可用)。 + /// + /// 用户名。 + /// 密码。 + /// 取消令牌。 + /// 是否初始化成功。 + public async Task BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken) + { + var normalizedUserName = (userName ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password)) + { + return false; + } + + var hasAnyCloudUser = await _dbContext.Users + .AsNoTracking() + .AnyAsync(u => u.Role != "local", cancellationToken); + + if (hasAnyCloudUser) + { + return false; + } + + var exists = await _dbContext.Users + .AsNoTracking() + .AnyAsync(u => u.UserName == normalizedUserName, cancellationToken); + + if (exists) + { + return false; + } + + var user = new UserEntity + { + Id = Guid.NewGuid(), + UserName = normalizedUserName, + Role = "admin" + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, password); + + _dbContext.Users.Add(user); + _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); + await _dbContext.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// 用户名密码登录并创建会话。 + /// + /// 用户名。 + /// 密码。 + /// 会话有效期。 + /// 取消令牌。 + /// 登录响应;失败则返回 null。 + public async Task LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken) + { + var normalizedUserName = (userName ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password)) + { + return null; + } + + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken); + if (user == null || user.Role == "local") + { + return null; + } + + var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (verify == PasswordVerificationResult.Failed) + { + return null; + } + + var now = DateTime.UtcNow; + var expiresAt = now.Add(sessionTtl); + + var session = new UserSessionEntity + { + Id = Guid.NewGuid(), + UserId = user.Id, + CreatedAtUtc = now, + ExpiresAtUtc = expiresAt + }; + + _dbContext.UserSessions.Add(session); + + var hasPolicy = await _dbContext.SecurityPolicies + .AsNoTracking() + .AnyAsync(p => p.UserId == user.Id, cancellationToken); + + if (!hasPolicy) + { + _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + return new LoginResponse + { + AccessToken = session.Id.ToString(), + ExpiresAtUtc = expiresAt, + UserId = user.Id, + Role = user.Role, + Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList() + }; + } + + /// + /// 通过再输入口令提升会话权限(step-up)。 + /// + /// 会话 ID。 + /// 口令。 + /// 二次认证有效期。 + /// 取消令牌。 + /// 有效期截止时间;失败返回 null。 + public async Task StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(password)) + { + return null; + } + + var now = DateTime.UtcNow; + + var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); + if (session == null || session.ExpiresAtUtc <= now) + { + return null; + } + + var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken); + if (user == null) + { + return null; + } + + var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (verify == PasswordVerificationResult.Failed) + { + return null; + } + + var stepUpExpiresAt = now.Add(stepUpTtl); + session.StepUpExpiresAtUtc = stepUpExpiresAt; + await _dbContext.SaveChangesAsync(cancellationToken); + + return stepUpExpiresAt; + } +} + diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs new file mode 100644 index 0000000..9a0bf29 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.Data; +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.CloudSync.Services; + +/// +/// 云同步任务服务(按用户隔离)。 +/// +public class CloudTaskSyncService +{ + private readonly TodoDbContext _dbContext; + + /// + /// 创建 。 + /// + /// 数据库上下文。 + public CloudTaskSyncService(TodoDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// 获取指定用户的任务全量。 + /// + /// 用户 ID。 + /// 取消令牌。 + /// 任务列表。 + public async Task> GetTasksAsync(Guid userId, CancellationToken cancellationToken) + { + var tasks = await _dbContext.Tasks + .AsNoTracking() + .Where(t => t.UserId == userId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken); + + return tasks.Select(MapToItem).ToList(); + } + + /// + /// 执行同步(增改删),并返回最新全量。 + /// + /// 用户 ID。 + /// 同步请求。 + /// 取消令牌。 + /// 同步响应。 + public async Task SyncAsync(Guid userId, SyncRequest request, CancellationToken cancellationToken) + { + request ??= new SyncRequest(); + + if (request.Deletes.Count > 0) + { + var deleteIds = request.Deletes.Distinct().ToList(); + var toDelete = await _dbContext.Tasks + .Where(t => t.UserId == userId && deleteIds.Contains(t.Id)) + .ToListAsync(cancellationToken); + + if (toDelete.Count > 0) + { + _dbContext.Tasks.RemoveRange(toDelete); + } + } + + if (request.Upserts.Count > 0) + { + foreach (var upsert in request.Upserts) + { + if (string.IsNullOrWhiteSpace(upsert.Title)) + { + continue; + } + + if (upsert.Id.HasValue) + { + var existing = await _dbContext.Tasks + .FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken); + + if (existing != null) + { + existing.Title = upsert.Title.Trim(); + existing.Priority = upsert.Priority; + existing.IsCompleted = upsert.IsCompleted; + existing.ParentTaskId = upsert.ParentTaskId; + existing.UpdatedAt = DateTime.UtcNow; + continue; + } + } + + var entity = new TaskEntity + { + UserId = userId, + Title = upsert.Title.Trim(), + Priority = upsert.Priority, + IsCompleted = upsert.IsCompleted, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ParentTaskId = upsert.ParentTaskId + }; + + _dbContext.Tasks.Add(entity); + } + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + return new SyncResponse + { + ServerTimeUtc = DateTime.UtcNow, + Tasks = await GetTasksAsync(userId, cancellationToken) + }; + } + + private static CloudTaskItem MapToItem(TaskEntity task) + { + return new CloudTaskItem + { + Id = task.Id, + Title = task.Title, + Priority = task.Priority, + IsCompleted = task.IsCompleted, + CreatedAtUtc = task.CreatedAt, + UpdatedAtUtc = task.UpdatedAt, + ParentTaskId = task.ParentTaskId + }; + } +} + diff --git a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs b/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs new file mode 100644 index 0000000..10d0ae1 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.Data; +using Hua.Todo.Core.Entities; + +namespace Hua.Todo.Application.CloudSync.Services; + +/// +/// 安全策略服务(按用户隔离)。 +/// +public class SecurityPolicyService +{ + private readonly TodoDbContext _dbContext; + + /// + /// 创建 。 + /// + /// 数据库上下文。 + public SecurityPolicyService(TodoDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// 获取指定用户的安全策略。 + /// + /// 用户 ID。 + /// 取消令牌。 + /// 策略 DTO。 + public async Task GetPolicyAsync(Guid userId, CancellationToken cancellationToken) + { + var policy = await _dbContext.SecurityPolicies + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + return new SecurityPolicyDto + { + AllowPersist = policy?.AllowPersist ?? true, + AllowSync = policy?.AllowSync ?? true, + RequireSecondFactorFor = new List { "sync:write", "policy:write" } + }; + } + + /// + /// 更新指定用户的安全策略(覆盖式更新)。 + /// + /// 用户 ID。 + /// 是否允许落盘。 + /// 取消令牌。 + /// 更新后的策略 DTO。 + public async Task UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken) + { + var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + if (policy == null) + { + policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync }; + _dbContext.SecurityPolicies.Add(policy); + } + else + { + policy.AllowPersist = allowPersist; + policy.AllowSync = allowSync; + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + return await GetPolicyAsync(userId, cancellationToken); + } +} diff --git a/src/Hua.Todo.Application/Data/TodoDbContext.cs b/src/Hua.Todo.Application/Data/TodoDbContext.cs index bb6f879..54a6c78 100644 --- a/src/Hua.Todo.Application/Data/TodoDbContext.cs +++ b/src/Hua.Todo.Application/Data/TodoDbContext.cs @@ -21,6 +21,21 @@ public class TodoDbContext : DbContext /// public DbSet Tasks { get; set; } + /// + /// 用户集合。 + /// + public DbSet Users { get; set; } + + /// + /// 用户会话集合。 + /// + public DbSet UserSessions { get; set; } + + /// + /// 安全策略集合。 + /// + public DbSet SecurityPolicies { get; set; } + /// /// 配置实体模型映射。 /// @@ -33,16 +48,58 @@ public class TodoDbContext : DbContext { entity.ToTable("Tasks"); entity.HasKey(e => e.Id); + entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId); entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium); entity.Property(e => e.IsCompleted).HasDefaultValue(false); entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')"); + entity.HasOne(e => e.User) + .WithMany(u => u.Tasks) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.ParentTask) .WithMany(e => e.SubTasks) .HasForeignKey(e => e.ParentTaskId) .OnDelete(DeleteBehavior.Restrict); }); + + modelBuilder.Entity(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(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(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); + }); } } diff --git a/src/Hua.Todo.Application/Hua.Todo.Application.csproj b/src/Hua.Todo.Application/Hua.Todo.Application.csproj index ff4eedd..075eb79 100644 --- a/src/Hua.Todo.Application/Hua.Todo.Application.csproj +++ b/src/Hua.Todo.Application/Hua.Todo.Application.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs b/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs new file mode 100644 index 0000000..a368608 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.Designer.cs @@ -0,0 +1,198 @@ +// +using System; +using Hua.Todo.Application.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20260406172936_AddCloudSyncCoreEntities")] + partial class AddCloudSyncCoreEntities + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ParentTaskId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs b/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs new file mode 100644 index 0000000..1c20020 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260406172936_AddCloudSyncCoreEntities.cs @@ -0,0 +1,136 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class AddCloudSyncCoreEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UserId", + table: "Tasks", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000001")); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 64, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", maxLength: 32, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "UserName", "PasswordHash", "Role" }, + values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "local", "", "local" }); + + migrationBuilder.CreateTable( + name: "SecurityPolicies", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + AllowPersist = table.Column(type: "INTEGER", nullable: false, defaultValue: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SecurityPolicies", x => x.Id); + table.ForeignKey( + name: "FK_SecurityPolicies_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false), + ExpiresAtUtc = table.Column(type: "TEXT", nullable: false), + StepUpExpiresAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Tasks_UserId", + table: "Tasks", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_SecurityPolicies_UserId", + table: "SecurityPolicies", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_UserName", + table: "Users", + column: "UserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + table: "UserSessions", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Tasks_Users_UserId", + table: "Tasks", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tasks_Users_UserId", + table: "Tasks"); + + migrationBuilder.DropTable( + name: "SecurityPolicies"); + + migrationBuilder.DropTable( + name: "UserSessions"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropIndex( + name: "IX_Tasks_UserId", + table: "Tasks"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Tasks"); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs b/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs new file mode 100644 index 0000000..a9c95e7 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.Designer.cs @@ -0,0 +1,203 @@ +// +using System; +using Hua.Todo.Application.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20260406173734_AddAllowSyncToSecurityPolicy")] + partial class AddAllowSyncToSecurityPolicy + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ParentTaskId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs b/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs new file mode 100644 index 0000000..f82db2c --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/20260406173734_AddAllowSyncToSecurityPolicy.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + public partial class AddAllowSyncToSecurityPolicy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowSync", + table: "SecurityPolicies", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowSync", + table: "SecurityPolicies"); + } + } +} diff --git a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs index 4e6620e..8a59e23 100644 --- a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs +++ b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs @@ -1,9 +1,9 @@ -// +// using System; +using Hua.Todo.Application.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Hua.Todo.Application.Data; #nullable disable @@ -17,6 +17,33 @@ namespace Hua.Todo.Application.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("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("Id") @@ -51,11 +78,82 @@ namespace Hua.Todo.Application.Migrations .HasColumnType("TEXT") .HasDefaultValueSql("datetime('now')"); + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001")); + b.HasKey("Id"); b.HasIndex("ParentTaskId"); - b.ToTable("Tasks"); + b.HasIndex("UserId"); + + b.ToTable("Tasks", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("StepUpExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("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 => @@ -65,13 +163,37 @@ namespace Hua.Todo.Application.Migrations .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 } } diff --git a/src/Hua.Todo.Application/Repositories/TaskRepository.cs b/src/Hua.Todo.Application/Repositories/TaskRepository.cs index b352d99..566d540 100644 --- a/src/Hua.Todo.Application/Repositories/TaskRepository.cs +++ b/src/Hua.Todo.Application/Repositories/TaskRepository.cs @@ -28,6 +28,7 @@ public class TaskRepository : ITaskRepository public async Task> GetAllAsync() { return await _context.Tasks + .Where(t => t.UserId == TodoUserIds.LocalUserId) .Include(t => t.SubTasks) .ToListAsync(); } @@ -41,7 +42,7 @@ public class TaskRepository : ITaskRepository { return await _context.Tasks .Include(t => t.SubTasks) - .FirstOrDefaultAsync(t => t.Id == id); + .FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId); } /// @@ -51,7 +52,7 @@ public class TaskRepository : ITaskRepository public async Task> GetActiveTasksAsync() { return await _context.Tasks - .Where(t => !t.IsCompleted) + .Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted) .OrderByDescending(t => t.CreatedAt) .ToListAsync(); } @@ -63,7 +64,7 @@ public class TaskRepository : ITaskRepository public async Task> GetCompletedTasksAsync() { return await _context.Tasks - .Where(t => t.IsCompleted) + .Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted) .OrderByDescending(t => t.UpdatedAt) .ToListAsync(); } @@ -75,6 +76,7 @@ public class TaskRepository : ITaskRepository /// 已持久化的任务实体(包含生成的 ID)。 public async Task AddAsync(TaskEntity taskEntity) { + taskEntity.UserId = TodoUserIds.LocalUserId; _context.Tasks.Add(taskEntity); await _context.SaveChangesAsync(); return taskEntity; @@ -100,7 +102,7 @@ public class TaskRepository : ITaskRepository /// 表示删除操作的任务。 public async Task DeleteAsync(int id) { - var task = await _context.Tasks.FindAsync(id); + var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId); if (task != null) { _context.Tasks.Remove(task); @@ -116,7 +118,7 @@ public class TaskRepository : ITaskRepository public async Task> GetSubTasksAsync(int parentTaskId) { return await _context.Tasks - .Where(t => t.ParentTaskId == parentTaskId) + .Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId) .OrderByDescending(t => t.CreatedAt) .ToListAsync(); } diff --git a/src/Hua.Todo.Avalonia/App.axaml b/src/Hua.Todo.Avalonia/App.axaml new file mode 100644 index 0000000..09bcd01 --- /dev/null +++ b/src/Hua.Todo.Avalonia/App.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Hua.Todo.Avalonia/App.axaml.cs b/src/Hua.Todo.Avalonia/App.axaml.cs new file mode 100644 index 0000000..f0e3603 --- /dev/null +++ b/src/Hua.Todo.Avalonia/App.axaml.cs @@ -0,0 +1,270 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using AvaloniaWebView; +using Hua.Todo.Avalonia.Models; +using Hua.Todo.Avalonia.Services; +using Hua.Todo.Avalonia.Services.Platforms; +using Hua.Todo.Avalonia.ViewModels; +using Hua.Todo.Avalonia.Views; +using System; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Hua.Todo.Avalonia; + +/// +/// Avalonia 应用入口。 +/// 负责启动嵌入式 WebServer、创建主窗口并初始化 WebView 能力。 +/// +public partial class App : global::Avalonia.Application +{ + private AppSettings? _appSettings; + private IEmbeddedWebServerService? _webServer; + private IHotKeySettingsService? _hotKeySettingsService; + private IGlobalHotKeyService? _hotKeyService; + private HotKeyConfig? _hotKeyConfig; + private WindowsKeyboardHandler? _keyboardHandler; + private MainWindow? _mainWindow; + private bool _isExitRequested; + private bool _isHotkeyRegistered; + + /// + /// 初始化应用资源并加载配置。 + /// + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + LoadSettings(); + } + + /// + /// 注册应用级服务。 + /// + public override void RegisterServices() + { + base.RegisterServices(); + AvaloniaWebViewBuilder.Initialize(default); + } + + private void LoadSettings() + { + try + { + var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (File.Exists(settingsPath)) + { + var json = File.ReadAllText(settingsPath); + _appSettings = JsonSerializer.Deserialize(json); + } + } + catch (Exception ex) + { + Console.WriteLine($"[App] Failed to load settings: {ex.Message}"); + } + + _appSettings ??= new AppSettings(); + + EnsureDefaultConnectionString(_appSettings); + } + + private static void EnsureDefaultConnectionString(AppSettings appSettings) + { + if (string.IsNullOrWhiteSpace(appSettings.WebServer.ConnectionString)) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var dbDir = Path.Combine(localAppData, "Hua.Todo"); + if (!Directory.Exists(dbDir)) + { + Directory.CreateDirectory(dbDir); + } + + var dbPath = Path.Combine(dbDir, "Hua.Todo.db"); + appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared"; + } + } + + /// + /// Avalonia 框架初始化完成回调。 + /// 启动嵌入式 WebServer(后台启动并捕获异常,避免阻塞/崩溃),并创建主窗口。 + /// + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + DisableAvaloniaDataAnnotationValidation(); + + _webServer = new EmbeddedWebServerService(_appSettings!); + _ = Task.Run(async () => + { + try + { + await _webServer.StartAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[App] Web server start failed: {ex}"); + } + }); + + _hotKeySettingsService = new HotKeySettingsService(_appSettings!); + _hotKeyConfig = _hotKeySettingsService.GetConfig(); + _hotKeyService = GlobalHotKeyServiceFactory.Create(); + + if (OperatingSystem.IsWindows()) + { + _keyboardHandler = new WindowsKeyboardHandler(); + _keyboardHandler.EscKeyPressed += (_, _) => + { + Dispatcher.UIThread.Post(() => + { + if (_mainWindow is { IsVisible: true }) + { + _mainWindow.WindowState = WindowState.Minimized; + } + }); + }; + } + + DataContext = new AppTrayViewModel( + AppMetadata.GetTrayTooltipText(), + ShowMainWindow, + () => ExitApplication(desktop)); + + _mainWindow = new MainWindow(_appSettings!, _webServer) + { + Width = 450, + Height = 640, + Title = AppMetadata.GetWindowTitle(), + }; + + _mainWindow.Opened += (_, _) => + { + RegisterHotkey(); + _keyboardHandler?.Start(); + }; + + _mainWindow.Closing += (_, e) => + { + if (_isExitRequested) + { + return; + } + + e.Cancel = true; + _mainWindow.Hide(); + }; + + desktop.MainWindow = _mainWindow; + + desktop.Exit += async (s, e) => + { + _keyboardHandler?.Dispose(); + _keyboardHandler = null; + + if (_isHotkeyRegistered) + { + _hotKeyService?.UnregisterHotKey(); + _isHotkeyRegistered = false; + } + + if (_hotKeyService is IDisposable disposableHotKeyService) + { + disposableHotKeyService.Dispose(); + } + + if (_webServer != null) + { + await _webServer.StopAsync(); + } + }; + } + + base.OnFrameworkInitializationCompleted(); + } + + private void ExitApplication(IClassicDesktopStyleApplicationLifetime desktop) + { + _isExitRequested = true; + desktop.Shutdown(); + } + + private void ShowMainWindow() + { + if (_mainWindow == null) return; + + if (!_mainWindow.IsVisible) + { + _mainWindow.Show(); + } + + if (_mainWindow.WindowState == WindowState.Minimized) + { + _mainWindow.WindowState = WindowState.Normal; + } + + _mainWindow.Activate(); + } + + private void RegisterHotkey() + { + if (_hotKeyService == null || _hotKeySettingsService == null) return; + + var config = _hotKeyConfig ?? _hotKeySettingsService.GetConfig(); + _hotKeyConfig = config; + + if (_hotKeyService.IsSupported && config.IsEnabled) + { + _hotKeyService.RegisterHotKey(config.Modifiers, config.Key, () => + { + Dispatcher.UIThread.Post(ShowMainWindow); + }); + _isHotkeyRegistered = true; + } + + config.PropertyChanged -= OnHotKeyConfigPropertyChanged; + config.PropertyChanged += OnHotKeyConfigPropertyChanged; + } + + private void OnHotKeyConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_hotKeyService == null || _hotKeySettingsService == null || _hotKeyConfig == null) return; + + if (e.PropertyName != nameof(HotKeyConfig.Modifiers) && + e.PropertyName != nameof(HotKeyConfig.Key) && + e.PropertyName != nameof(HotKeyConfig.IsEnabled)) + { + return; + } + + _hotKeySettingsService.SaveConfig(_hotKeyConfig); + + if (_hotKeyConfig.IsEnabled && _hotKeyService.IsSupported) + { + _hotKeyService.UpdateHotKey(_hotKeyConfig.Modifiers, _hotKeyConfig.Key); + _isHotkeyRegistered = true; + } + else + { + _hotKeyService.UnregisterHotKey(); + _isHotkeyRegistered = false; + } + } + + private void DisableAvaloniaDataAnnotationValidation() + { + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + foreach (var plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } +} diff --git a/src/Hua.Todo.Avalonia/Assets/avalonia-logo.ico b/src/Hua.Todo.Avalonia/Assets/avalonia-logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..f7da8bb5863b7cecec2adcdebd948fe2f9418d0c GIT binary patch literal 175875 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 + + WinExe + net10.0 + enable + app.manifest + true + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web')) + $(TodoWebDir)\dist + false + false + + + + + + + + + + + + + + + + + None + All + + + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + <_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" /> + + + + + + + + diff --git a/src/Hua.Todo.Avalonia/Models/AppSettings.cs b/src/Hua.Todo.Avalonia/Models/AppSettings.cs new file mode 100644 index 0000000..3d531ba --- /dev/null +++ b/src/Hua.Todo.Avalonia/Models/AppSettings.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace Hua.Todo.Avalonia.Models; + +public class AppSettings +{ + [JsonPropertyName("WebServer")] + public WebServerSettings WebServer { get; set; } = new(); + + [JsonPropertyName("HotKey")] + public HotKeyDefaultSettings HotKey { get; set; } = new(); +} + +public class WebServerSettings +{ + [JsonPropertyName("Port")] + public int Port { get; set; } = 5057; + + [JsonPropertyName("IsUsingStatic")] + public bool IsUsingStatic { get; set; } = true; + + [JsonPropertyName("ConnectionString")] + public string ConnectionString { get; set; } = ""; + + [JsonPropertyName("HostUrl")] + public string HostUrl { get; set; } = "http://localhost:5057"; + + [JsonPropertyName("ForEndUrl")] + public string ForEndUrl { get; set; } = "http://localhost:5174"; +} + +public class HotKeyDefaultSettings +{ + [JsonPropertyName("DefaultModifiers")] + public string DefaultModifiers { get; set; } = "Alt"; + + [JsonPropertyName("DefaultKey")] + public string DefaultKey { get; set; } = "X"; + + [JsonPropertyName("DefaultIsEnabled")] + public bool DefaultIsEnabled { get; set; } = true; +} diff --git a/src/Hua.Todo.Avalonia/Models/HotKeyConfig.cs b/src/Hua.Todo.Avalonia/Models/HotKeyConfig.cs new file mode 100644 index 0000000..864cc12 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Models/HotKeyConfig.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Hua.Todo.Avalonia.Models; + +/// +/// 热键配置模型。 +/// 用于描述全局热键的修饰键、主键与启用状态,并通过通知机制驱动动态更新。 +/// +public sealed class HotKeyConfig : INotifyPropertyChanged +{ + private string _modifiers = "Alt"; + private string _key = "X"; + private bool _isEnabled = true; + + /// + /// 修饰键(例如 Alt、Control、Shift、Windows;多个键用逗号分隔)。 + /// + public string Modifiers + { + get => _modifiers; + set + { + if (_modifiers != value) + { + _modifiers = value; + OnPropertyChanged(); + } + } + } + + /// + /// 主键(例如 X、C、V)。 + /// + public string Key + { + get => _key; + set + { + if (_key != value) + { + _key = value; + OnPropertyChanged(); + } + } + } + + /// + /// 是否启用热键。 + /// + public bool IsEnabled + { + get => _isEnabled; + set + { + if (_isEnabled != value) + { + _isEnabled = value; + OnPropertyChanged(); + } + } + } + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} + diff --git a/src/Hua.Todo.Avalonia/Program.cs b/src/Hua.Todo.Avalonia/Program.cs new file mode 100644 index 0000000..c2d7bd8 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Program.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.WebView.Desktop; +using System; + +namespace Hua.Todo.Avalonia; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace() + .UseDesktopWebView(); +} diff --git a/src/Hua.Todo.Avalonia/Services/AppMetadata.cs b/src/Hua.Todo.Avalonia/Services/AppMetadata.cs new file mode 100644 index 0000000..b359b95 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/AppMetadata.cs @@ -0,0 +1,60 @@ +using System.Reflection; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 应用元信息工具类。 +/// 负责生成窗口标题与托盘提示文本等展示字符串,避免在多处重复拼装并保持跨宿主一致性。 +/// +public static class AppMetadata +{ + private const string AppNameText = "待办事项"; + + /// + /// 应用名称(不含版本)。 + /// + public static string AppName => AppNameText; + + /// + /// 获取显示版本号(主版本.次版本.修订版本)。 + /// 若无法解析版本信息则返回 null。 + /// + public static string? GetDisplayVersion() + { + var asmVersion = Assembly.GetExecutingAssembly().GetName().Version; + if (asmVersion == null) + { + return null; + } + + return $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}"; + } + + /// + /// 获取托盘/关于等场景使用的显示标题(可能包含版本)。 + /// + public static string GetDisplayTitle() + { + var version = GetDisplayVersion(); + return string.IsNullOrWhiteSpace(version) ? AppName : $"{AppName} v{version}"; + } + + /// + /// 获取窗口标题栏文本(可能包含版本)。 + /// + public static string GetWindowTitle() + { + var version = GetDisplayVersion(); + return string.IsNullOrWhiteSpace(version) ? AppNameText : $"{AppNameText} v{version}"; + } + + /// + /// 获取托盘 Tooltip 文本(部分平台对长度有限制)。 + /// + public static string GetTrayTooltipText() + { + var text = GetDisplayTitle(); + return text.Length > 63 ? text[..63] : text; + } +} + diff --git a/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs b/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs new file mode 100644 index 0000000..412577b --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs @@ -0,0 +1,208 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Hua.Todo.Application; +using Hua.Todo.Application.Data; +using Hua.Todo.Application.DynamicApi; +using Hua.Todo.Avalonia.Models; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 嵌入式 Web 服务器实现 +/// +public class EmbeddedWebServerService : IEmbeddedWebServerService +{ + private WebApplication? _webApp; + private readonly AppSettings _appSettings; + + /// + /// 服务器是否正在运行 + /// + public bool IsRunning => _webApp != null; + + /// + /// 服务器基础 URL + /// + public string BaseUrl => _appSettings.WebServer.HostUrl; + + /// + /// 初始化嵌入式 Web 服务器服务 + /// + /// 应用程序配置 + public EmbeddedWebServerService(AppSettings appSettings) + { + _appSettings = appSettings; + } + + /// + /// 异步启动服务器 + /// + public async Task StartAsync() + { + if (_webApp != null) return; + + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl); + + // 配置控制器和 JSON 选项 + builder.Services.AddControllers() + .AddApplicationPart(typeof(Hua.Todo.Application.ServiceCollectionExtensions).Assembly) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + builder.Services.AddEndpointsApiExplorer(); + + // 注册应用逻辑服务 + builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); + + // 配置跨域策略 + builder.Services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + InitializeDatabase(app); + + // 如果配置为使用静态文件(前端托管),则配置静态文件服务 + if (_appSettings.WebServer.IsUsingStatic) + { + ServeStaticFiles(app); + } + + app.UseCors("AllowAll"); + app.UseAuthorization(); + app.UseDynamicApi(); + app.MapControllers(); + + _webApp = app; + + await _webApp.StartAsync(); + } + + private void InitializeDatabase(WebApplication app) + { + using var scope = app.Services.CreateScope(); + + try + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var sqliteBuilder = new SqliteConnectionStringBuilder(_appSettings.WebServer.ConnectionString); + var actualDbPath = sqliteBuilder.DataSource; + if (!string.IsNullOrEmpty(actualDbPath)) + { + var dbDir = Path.GetDirectoryName(actualDbPath); + if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir)) + { + Directory.CreateDirectory(dbDir); + } + } + + dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;"); + dbContext.Database.Migrate(); + } + catch (Exception ex) + { + Console.WriteLine($"[EmbeddedWebServer] Database initialization failed: {ex.Message}"); + + try + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); + } + catch (Exception ensureEx) + { + Console.WriteLine($"[EmbeddedWebServer] Database ensure-created failed: {ensureEx.Message}"); + } + } + } + + /// + /// 配置静态文件服务(用于托管 Vue 前端) + /// + /// Web 应用程序实例 + private void ServeStaticFiles(WebApplication app) + { + try + { + var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + if (!Directory.Exists(wwwrootPath)) + { + Console.WriteLine($"[EmbeddedWebServer] wwwroot directory not found at: {wwwrootPath}. Static file serving disabled."); + return; + } + var fileProvider = new PhysicalFileProvider(wwwrootPath); + var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider, RequestPath = "" }; + app.UseDefaultFiles(defaultFilesOptions); + + var staticFileOptions = new StaticFileOptions + { + FileProvider = fileProvider, + RequestPath = "", + OnPrepareResponse = ctx => + { + ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; + ctx.Context.Response.Headers["Pragma"] = "no-cache"; + ctx.Context.Response.Headers["Expires"] = "0"; + } + }; + app.UseStaticFiles(staticFileOptions); + + // 处理 SPA 路由 + app.Use(async (context, next) => + { + if (context.Request.Path.HasValue) + { + var path = context.Request.Path.Value; + if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase)) + { + var ext = Path.GetExtension(path); + if (string.IsNullOrEmpty(ext)) + { + context.Request.Path = "/index.html"; + } + } + } + await next(); + }); + + Console.WriteLine($"[EmbeddedWebServer] Serving static files from: {wwwrootPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[EmbeddedWebServer] Failed to serve static files: {ex.Message}"); + } + } + + /// + /// 异步停止服务器 + /// + public async Task StopAsync() + { + if (_webApp == null) return; + + await _webApp.StopAsync(); + await _webApp.DisposeAsync(); + _webApp = null; + } +} diff --git a/src/Hua.Todo.Avalonia/Services/GlobalHotKeyServiceFactory.cs b/src/Hua.Todo.Avalonia/Services/GlobalHotKeyServiceFactory.cs new file mode 100644 index 0000000..a61cc9b --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/GlobalHotKeyServiceFactory.cs @@ -0,0 +1,25 @@ +using Hua.Todo.Avalonia.Services.Platforms; +using System; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 全局热键服务工厂。 +/// 通过运行时平台判断返回最合适的实现,避免在同一文件内混写大量条件编译代码。 +/// +public static class GlobalHotKeyServiceFactory +{ + /// + /// 创建平台对应的全局热键服务实现。 + /// + public static IGlobalHotKeyService Create() + { + if (OperatingSystem.IsWindows()) + { + return new WindowsGlobalHotKeyService(); + } + + return new NoopGlobalHotKeyService(); + } +} + diff --git a/src/Hua.Todo.Avalonia/Services/HotKeySettingsService.cs b/src/Hua.Todo.Avalonia/Services/HotKeySettingsService.cs new file mode 100644 index 0000000..9cfbd0d --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/HotKeySettingsService.cs @@ -0,0 +1,130 @@ +using Hua.Todo.Avalonia.Models; +using System; +using System.IO; +using System.Text.Json; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 热键设置服务接口。 +/// 负责读取/保存用户可配置的全局热键,并提供从默认值重置的能力。 +/// +public interface IHotKeySettingsService +{ + /// + /// 获取当前热键配置(若尚未持久化则返回默认配置)。 + /// + HotKeyConfig GetConfig(); + + /// + /// 保存热键配置。 + /// + /// 要保存的热键配置。 + void SaveConfig(HotKeyConfig config); + + /// + /// 重置为默认配置并落盘。 + /// + void ResetToDefault(); +} + +/// +/// 热键设置服务实现。 +/// Avalonia 侧使用本地 JSON 文件持久化配置,以保证桌面端与多平台运行时一致性。 +/// +public sealed class HotKeySettingsService : IHotKeySettingsService +{ + private const string SettingsFileName = "hotkey.json"; + private readonly AppSettings _appSettings; + private HotKeyConfig? _cached; + + /// + /// 创建 。 + /// + /// 应用配置(用于提供默认热键)。 + public HotKeySettingsService(AppSettings appSettings) + { + _appSettings = appSettings; + } + + /// + public HotKeyConfig GetConfig() + { + if (_cached != null) + { + return _cached; + } + + var path = GetSettingsPath(); + if (!File.Exists(path)) + { + _cached = GetDefaultConfig(); + return _cached; + } + + try + { + var json = File.ReadAllText(path); + _cached = JsonSerializer.Deserialize(json) ?? GetDefaultConfig(); + return _cached; + } + catch + { + _cached = GetDefaultConfig(); + return _cached; + } + } + + /// + public void SaveConfig(HotKeyConfig config) + { + var path = GetSettingsPath(); + EnsureSettingsDirectory(path); + + var json = JsonSerializer.Serialize(config); + File.WriteAllText(path, json); + + _cached ??= config; + } + + /// + public void ResetToDefault() + { + var defaultConfig = GetDefaultConfig(); + var current = GetConfig(); + + current.Modifiers = defaultConfig.Modifiers; + current.Key = defaultConfig.Key; + current.IsEnabled = defaultConfig.IsEnabled; + + SaveConfig(current); + } + + private HotKeyConfig GetDefaultConfig() + { + return new HotKeyConfig + { + Modifiers = _appSettings.HotKey.DefaultModifiers, + Key = _appSettings.HotKey.DefaultKey, + IsEnabled = _appSettings.HotKey.DefaultIsEnabled + }; + } + + private static string GetSettingsPath() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var dir = Path.Combine(localAppData, "Hua.Todo"); + return Path.Combine(dir, SettingsFileName); + } + + private static void EnsureSettingsDirectory(string settingsPath) + { + var dir = Path.GetDirectoryName(settingsPath); + if (string.IsNullOrWhiteSpace(dir)) + { + return; + } + + Directory.CreateDirectory(dir); + } +} diff --git a/src/Hua.Todo.Avalonia/Services/IEmbeddedWebServerService.cs b/src/Hua.Todo.Avalonia/Services/IEmbeddedWebServerService.cs new file mode 100644 index 0000000..9ccf9b7 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/IEmbeddedWebServerService.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 嵌入式 Web 服务器接口 +/// +public interface IEmbeddedWebServerService +{ + /// + /// 服务器是否正在运行 + /// + bool IsRunning { get; } + + /// + /// 服务器基础 URL + /// + string BaseUrl { get; } + + /// + /// 异步启动服务器 + /// + Task StartAsync(); + + /// + /// 异步停止服务器 + /// + Task StopAsync(); +} diff --git a/src/Hua.Todo.Avalonia/Services/IGlobalHotKeyService.cs b/src/Hua.Todo.Avalonia/Services/IGlobalHotKeyService.cs new file mode 100644 index 0000000..b72c013 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/IGlobalHotKeyService.cs @@ -0,0 +1,36 @@ +using System; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 全局热键服务接口。 +/// 用于在桌面环境下注册系统级快捷键,以便在应用隐藏/最小化时快速唤起主窗口。 +/// +public interface IGlobalHotKeyService +{ + /// + /// 注册全局热键。 + /// + /// 修饰键(如 Alt、Control;多个键用逗号分隔)。 + /// 主键(如 X、C)。 + /// 热键触发时回调(不得阻塞,应自行切回 UI 线程)。 + void RegisterHotKey(string modifiers, string key, Action callback); + + /// + /// 注销已注册的热键。 + /// + void UnregisterHotKey(); + + /// + /// 更新热键配置。 + /// + /// 新的修饰键。 + /// 新的主键。 + void UpdateHotKey(string modifiers, string key); + + /// + /// 当前平台是否支持全局热键。 + /// + bool IsSupported { get; } +} + diff --git a/src/Hua.Todo.Avalonia/Services/NoopGlobalHotKeyService.cs b/src/Hua.Todo.Avalonia/Services/NoopGlobalHotKeyService.cs new file mode 100644 index 0000000..f5b7289 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/NoopGlobalHotKeyService.cs @@ -0,0 +1,29 @@ +using System; + +namespace Hua.Todo.Avalonia.Services; + +/// +/// 不支持全局热键的平台默认实现。 +/// 该实现不会注册任何系统级热键,用于保持上层逻辑简洁一致。 +/// +public sealed class NoopGlobalHotKeyService : IGlobalHotKeyService +{ + /// + public void RegisterHotKey(string modifiers, string key, Action callback) + { + } + + /// + public void UnregisterHotKey() + { + } + + /// + public void UpdateHotKey(string modifiers, string key) + { + } + + /// + public bool IsSupported => false; +} + diff --git a/src/Hua.Todo.Avalonia/Services/Platforms/WindowsGlobalHotKeyService.cs b/src/Hua.Todo.Avalonia/Services/Platforms/WindowsGlobalHotKeyService.cs new file mode 100644 index 0000000..3340791 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/Platforms/WindowsGlobalHotKeyService.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Hua.Todo.Avalonia.Services.Platforms; + +/// +/// Windows 平台全局热键服务实现。 +/// 通过 RegisterHotKey 将热键注册到专用线程的消息队列中,避免依赖窗口句柄与 WndProc Hook。 +/// +public sealed class WindowsGlobalHotKeyService : IGlobalHotKeyService, IDisposable +{ + private const int HotKeyId = 9000; + private const uint WmHotKey = 0x0312; + private const uint WmQuit = 0x0012; + private const uint WmAppExecute = 0x8001; + + public const uint ModAlt = 0x0001; + public const uint ModControl = 0x0002; + public const uint ModShift = 0x0004; + public const uint ModWin = 0x0008; + + private readonly ConcurrentDictionary _actions = new(); + private int _actionId; + private Thread? _thread; + private uint _threadId; + private ManualResetEventSlim? _threadStarted; + + private Action? _callback; + private bool _isRegistered; + private uint _currentModifiers; + private uint _currentKey; + private bool _isDisposed; + + /// + public bool IsSupported => OperatingSystem.IsWindows(); + + /// + public void RegisterHotKey(string modifiers, string key, Action callback) + { + if (!IsSupported) return; + + _callback = callback; + _currentModifiers = ParseModifiers(modifiers); + _currentKey = ParseKey(key); + + StartThreadIfNeeded(); + InvokeOnHotKeyThread(() => + { + UnregisterHotKeyInternal(); + + if (_currentKey == 0) + { + return; + } + + if (RegisterHotKey(IntPtr.Zero, HotKeyId, _currentModifiers, _currentKey)) + { + _isRegistered = true; + } + else + { + Debug.WriteLine("[WindowsGlobalHotKeyService] Failed to register hotkey."); + } + }); + } + + /// + public void UnregisterHotKey() + { + if (!IsSupported) return; + + StartThreadIfNeeded(); + InvokeOnHotKeyThread(UnregisterHotKeyInternal); + } + + /// + public void UpdateHotKey(string modifiers, string key) + { + if (_callback == null) return; + RegisterHotKey(modifiers, key, _callback); + } + + private void UnregisterHotKeyInternal() + { + if (_isRegistered) + { + UnregisterHotKey(IntPtr.Zero, HotKeyId); + _isRegistered = false; + } + } + + private void StartThreadIfNeeded() + { + if (_thread != null) return; + + _threadStarted = new ManualResetEventSlim(false); + _thread = new Thread(MessageLoop) + { + IsBackground = true, + Name = "Hua.Todo HotKey Thread" + }; + if (OperatingSystem.IsWindows()) + { + _thread.SetApartmentState(ApartmentState.STA); + } + _thread.Start(); + _threadStarted.Wait(); + } + + private void MessageLoop() + { + _threadId = GetCurrentThreadId(); + _threadStarted?.Set(); + + while (GetMessage(out var msg, IntPtr.Zero, 0, 0) != 0) + { + if (msg.message == WmHotKey && msg.wParam == (UIntPtr)HotKeyId) + { + try + { + _callback?.Invoke(); + } + catch (Exception ex) + { + Debug.WriteLine($"[WindowsGlobalHotKeyService] Hotkey callback failed: {ex}"); + } + continue; + } + + if (msg.message == WmAppExecute) + { + var id = unchecked((int)msg.wParam); + if (_actions.TryRemove(id, out var action)) + { + try + { + action(); + } + catch (Exception ex) + { + Debug.WriteLine($"[WindowsGlobalHotKeyService] Action execution failed: {ex}"); + } + } + continue; + } + + if (msg.message == WmQuit) + { + break; + } + + TranslateMessage(ref msg); + DispatchMessage(ref msg); + } + + _threadStarted?.Dispose(); + _threadStarted = null; + } + + private void InvokeOnHotKeyThread(Action action) + { + var id = Interlocked.Increment(ref _actionId); + _actions[id] = action; + + if (!PostThreadMessage(_threadId, WmAppExecute, (UIntPtr)id, IntPtr.Zero)) + { + _actions.TryRemove(id, out _); + } + } + + private static uint ParseModifiers(string modifiers) + { + uint mod = 0; + if (string.IsNullOrWhiteSpace(modifiers)) return mod; + + var parts = modifiers.Split(','); + foreach (var part in parts) + { + var p = part.Trim(); + if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= ModControl; + if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= ModAlt; + if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= ModShift; + if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= ModWin; + } + + return mod; + } + + private static uint ParseKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) return 0; + + if (key.Length == 1) + { + var c = char.ToUpperInvariant(key[0]); + if (c is >= 'A' and <= 'Z') return c; + if (c is >= '0' and <= '9') return c; + } + + return 0x58; // Default 'X' + } + + /// + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + + try + { + UnregisterHotKey(); + } + catch + { + } + + if (_threadId != 0) + { + PostThreadMessage(_threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct Msg + { + public IntPtr hwnd; + public uint message; + public UIntPtr wParam; + public IntPtr lParam; + public uint time; + public POINT pt; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int x; + public int y; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + [DllImport("user32.dll")] + private static extern sbyte GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref Msg lpMsg); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref Msg lpMsg); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostThreadMessage(uint idThread, uint msg, UIntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); +} diff --git a/src/Hua.Todo.Avalonia/Services/Platforms/WindowsKeyboardHandler.cs b/src/Hua.Todo.Avalonia/Services/Platforms/WindowsKeyboardHandler.cs new file mode 100644 index 0000000..f09ef97 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Services/Platforms/WindowsKeyboardHandler.cs @@ -0,0 +1,138 @@ +using System; +using System.Runtime.InteropServices; + +namespace Hua.Todo.Avalonia.Services.Platforms; + +/// +/// Windows 全局键盘事件处理器。 +/// 用于监听按键并向上层暴露事件(例如 Esc),以保证在 WebView 获得焦点时依旧可触发窗口级交互。 +/// +public sealed class WindowsKeyboardHandler : IDisposable +{ + private readonly KeyboardHook _keyboardHook; + private bool _isDisposed; + + /// + /// 当检测到 Esc 键抬起时触发。 + /// + public event EventHandler? EscKeyPressed; + + /// + /// 创建 。 + /// + public WindowsKeyboardHandler() + { + _keyboardHook = new KeyboardHook(); + _keyboardHook.KeyPressed += OnKeyPressed; + } + + /// + /// 开始监听键盘事件。 + /// + public void Start() + { + _keyboardHook.Hook(); + } + + private void OnKeyPressed(object? sender, KeyPressedEventArgs e) + { + if (e.Key == 0x1B && !e.IsKeyDown) + { + EscKeyPressed?.Invoke(this, EventArgs.Empty); + } + } + + /// + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + + _keyboardHook.Unhook(); + _keyboardHook.KeyPressed -= OnKeyPressed; + } + + private sealed class KeyboardHook : IDisposable + { + private const int WhKeyboardLl = 13; + private const int WmKeyDown = 0x0100; + private const int WmKeyUp = 0x0101; + private const int WmSysKeyDown = 0x0104; + private const int WmSysKeyUp = 0x0105; + + private readonly LowLevelKeyboardProc _proc; + private IntPtr _hookId = IntPtr.Zero; + private bool _isDisposed; + + public event EventHandler? KeyPressed; + + public KeyboardHook() + { + _proc = HookCallback; + } + + public void Hook() + { + if (_hookId != IntPtr.Zero) return; + _hookId = SetWindowsHookEx(WhKeyboardLl, _proc, GetModuleHandle(null), 0); + } + + public void Unhook() + { + if (_hookId == IntPtr.Zero) return; + UnhookWindowsHookEx(_hookId); + _hookId = IntPtr.Zero; + } + + private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode >= 0) + { + var vkCode = Marshal.ReadInt32(lParam); + var isKeyDown = wParam == (IntPtr)WmKeyDown || wParam == (IntPtr)WmSysKeyDown; + var isKeyUp = wParam == (IntPtr)WmKeyUp || wParam == (IntPtr)WmSysKeyUp; + + if (isKeyDown || isKeyUp) + { + KeyPressed?.Invoke(this, new KeyPressedEventArgs(vkCode, isKeyDown)); + } + } + + return CallNextHookEx(_hookId, nCode, wParam, lParam); + } + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + Unhook(); + } + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern bool UnhookWindowsHookEx(IntPtr hhk); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern IntPtr GetModuleHandle(string? lpModuleName); + } + + private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); + + private sealed class KeyPressedEventArgs : EventArgs + { + public int Key { get; } + public bool IsKeyDown { get; } + + public KeyPressedEventArgs(int key, bool isKeyDown) + { + Key = key; + IsKeyDown = isKeyDown; + } + } +} + diff --git a/src/Hua.Todo.Avalonia/ViewLocator.cs b/src/Hua.Todo.Avalonia/ViewLocator.cs new file mode 100644 index 0000000..1a0b661 --- /dev/null +++ b/src/Hua.Todo.Avalonia/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Hua.Todo.Avalonia.ViewModels; + +namespace Hua.Todo.Avalonia; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/src/Hua.Todo.Avalonia/ViewModels/AppTrayViewModel.cs b/src/Hua.Todo.Avalonia/ViewModels/AppTrayViewModel.cs new file mode 100644 index 0000000..a06cf3e --- /dev/null +++ b/src/Hua.Todo.Avalonia/ViewModels/AppTrayViewModel.cs @@ -0,0 +1,47 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System; + +namespace Hua.Todo.Avalonia.ViewModels; + +/// +/// 应用托盘交互的 ViewModel。 +/// 用于为 提供命令与提示文本,并将托盘事件路由到应用层逻辑。 +/// +public sealed partial class AppTrayViewModel : ObservableObject +{ + private readonly Action _showMainWindow; + private readonly Action _exitApplication; + + /// + /// 创建 。 + /// + /// 托盘提示文本(部分平台有长度限制)。 + /// 显示/激活主窗口回调。 + /// 退出应用回调(应触发应用生命周期关闭)。 + public AppTrayViewModel(string trayTooltipText, Action showMainWindow, Action exitApplication) + { + TrayTooltipText = trayTooltipText; + _showMainWindow = showMainWindow; + _exitApplication = exitApplication; + + ShowMainWindowCommand = new RelayCommand(_showMainWindow); + ExitApplicationCommand = new RelayCommand(_exitApplication); + } + + /// + /// 托盘提示文本。 + /// + public string TrayTooltipText { get; } + + /// + /// 显示/激活主窗口命令。 + /// + public IRelayCommand ShowMainWindowCommand { get; } + + /// + /// 退出应用命令。 + /// + public IRelayCommand ExitApplicationCommand { get; } +} + diff --git a/src/Hua.Todo.Avalonia/ViewModels/MainWindowViewModel.cs b/src/Hua.Todo.Avalonia/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..bfbd6d9 --- /dev/null +++ b/src/Hua.Todo.Avalonia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,6 @@ +namespace Hua.Todo.Avalonia.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + public string Greeting { get; } = "Welcome to Avalonia!"; +} diff --git a/src/Hua.Todo.Avalonia/ViewModels/ViewModelBase.cs b/src/Hua.Todo.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..173e739 --- /dev/null +++ b/src/Hua.Todo.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Hua.Todo.Avalonia.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/src/Hua.Todo.Avalonia/Views/MainWindow.axaml b/src/Hua.Todo.Avalonia/Views/MainWindow.axaml new file mode 100644 index 0000000..57a9cab --- /dev/null +++ b/src/Hua.Todo.Avalonia/Views/MainWindow.axaml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs b/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..9fa89e6 --- /dev/null +++ b/src/Hua.Todo.Avalonia/Views/MainWindow.axaml.cs @@ -0,0 +1,106 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Hua.Todo.Avalonia.Models; +using Hua.Todo.Avalonia.Services; +using System; + +namespace Hua.Todo.Avalonia.Views; + +/// +/// 应用主窗口。 +/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。 +/// +public partial class MainWindow : Window +{ + private readonly AppSettings _appSettings; + private readonly IEmbeddedWebServerService _webServer; + + /// + /// 创建用于设计器预览的主窗口实例。 + /// + public MainWindow() + { + InitializeComponent(); + _appSettings = new AppSettings(); + _webServer = new EmbeddedWebServerService(_appSettings); + } + + /// + /// 创建运行时主窗口实例。 + /// + /// 应用配置。 + /// 嵌入式 WebServer。 + public MainWindow(AppSettings appSettings, IEmbeddedWebServerService webServer) + { + InitializeComponent(); + _appSettings = appSettings; + _webServer = webServer; + + SetupWebView(); + } + + private void SetupWebView() + { + try + { + if (_appSettings.WebServer.IsUsingStatic) + { + MainWebView.Url = new Uri(_webServer.BaseUrl); + } + else + { + MainWebView.Url = new Uri(_appSettings.WebServer.ForEndUrl); + } + + MainWebView.NavigationCompleted += async (s, e) => + { + try + { + if (_webServer.IsRunning) + { + var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api"; + await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';"); + } + + await MainWebView.ExecuteScriptAsync(@" + window.mauiInterop = { + onHotKeyConfigUpdated: null, + openHotKeySettings: function(config) { + const event = new CustomEvent('openHotKeySettings', { detail: config }); + window.dispatchEvent(event); + }, + updateHotKeyConfig: function(modifiers, key, isEnabled) { + const event = new CustomEvent('updateHotKeyConfig', { + detail: { modifiers, key, isEnabled } + }); + window.dispatchEvent(event); + } + }; + + window.addEventListener('hotKeyConfigChanged', function(e) { + if (window.mauiInterop.onHotKeyConfigUpdated) { + window.mauiInterop.onHotKeyConfigUpdated(e.detail); + } + }); + "); + } + catch (Exception ex) + { + Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}"); + } + }; + } + catch (Exception ex) + { + Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}"); + Content = new TextBlock + { + Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 Runtime;Linux 需要 WebKitGTK)。", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap + }; + } + } +} diff --git a/src/Hua.Todo.Avalonia/app.manifest b/src/Hua.Todo.Avalonia/app.manifest new file mode 100644 index 0000000..1dd0528 --- /dev/null +++ b/src/Hua.Todo.Avalonia/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/src/Hua.Todo.Avalonia/appsettings.json b/src/Hua.Todo.Avalonia/appsettings.json new file mode 100644 index 0000000..c4333de --- /dev/null +++ b/src/Hua.Todo.Avalonia/appsettings.json @@ -0,0 +1,14 @@ +{ + "WebServer": { + "Port": 5057, + "IsUsingStatic": false, + "ConnectionString": "", + "HostUrl": "http://localhost:5057", + "ForEndUrl": "http://localhost:5174" + }, + "HotKey": { + "DefaultModifiers": "Alt", + "DefaultKey": "X", + "DefaultIsEnabled": true + } +} diff --git a/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs b/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs new file mode 100644 index 0000000..f341ad7 --- /dev/null +++ b/src/Hua.Todo.Core/Entities/SecurityPolicyEntity.cs @@ -0,0 +1,32 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 安全策略配置(服务端下发)。 +/// +public class SecurityPolicyEntity +{ + /// + /// 策略唯一标识符。 + /// + public Guid Id { get; set; } + + /// + /// 策略所属用户 ID。 + /// + public Guid UserId { get; set; } + + /// + /// 策略所属用户导航属性。 + /// + public UserEntity? User { get; set; } + + /// + /// 是否允许客户端落盘(为 false 时客户端应进入“内存模式”,不产生本地持久化)。 + /// + public bool AllowPersist { get; set; } = true; + + /// + /// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。 + /// + public bool AllowSync { get; set; } = true; +} diff --git a/src/Hua.Todo.Core/Entities/TaskEntity.cs b/src/Hua.Todo.Core/Entities/TaskEntity.cs index 998e4e4..242759d 100644 --- a/src/Hua.Todo.Core/Entities/TaskEntity.cs +++ b/src/Hua.Todo.Core/Entities/TaskEntity.cs @@ -5,6 +5,17 @@ namespace Hua.Todo.Core.Entities; /// public class TaskEntity { + /// + /// 任务所属用户 ID。 + /// 本地模式下使用固定的“本地用户”ID,以确保本地任务与云端多用户任务隔离。 + /// + public Guid UserId { get; set; } = TodoUserIds.LocalUserId; + + /// + /// 任务所属用户导航属性。 + /// + public UserEntity? User { get; set; } + /// /// 任务唯一标识符 /// diff --git a/src/Hua.Todo.Core/Entities/TodoUserIds.cs b/src/Hua.Todo.Core/Entities/TodoUserIds.cs new file mode 100644 index 0000000..f50fdb5 --- /dev/null +++ b/src/Hua.Todo.Core/Entities/TodoUserIds.cs @@ -0,0 +1,13 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 系统内置用户 ID 常量。 +/// +public static class TodoUserIds +{ + /// + /// 本地模式专用的内置用户 ID。 + /// + public static readonly Guid LocalUserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); +} + diff --git a/src/Hua.Todo.Core/Entities/UserEntity.cs b/src/Hua.Todo.Core/Entities/UserEntity.cs new file mode 100644 index 0000000..ff4294e --- /dev/null +++ b/src/Hua.Todo.Core/Entities/UserEntity.cs @@ -0,0 +1,33 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 用户实体,用于云同步场景下的用户隔离与权限控制。 +/// +public class UserEntity +{ + /// + /// 用户唯一标识符。 + /// + public Guid Id { get; set; } + + /// + /// 登录用户名(全局唯一)。 + /// + public string UserName { get; set; } = string.Empty; + + /// + /// 登录密码哈希(不存储明文)。 + /// + public string PasswordHash { get; set; } = string.Empty; + + /// + /// 用户角色(用于 RBAC 权限映射)。 + /// + public string Role { get; set; } = "user"; + + /// + /// 用户任务集合。 + /// + public List Tasks { get; set; } = new(); +} + diff --git a/src/Hua.Todo.Core/Entities/UserSessionEntity.cs b/src/Hua.Todo.Core/Entities/UserSessionEntity.cs new file mode 100644 index 0000000..0b2a061 --- /dev/null +++ b/src/Hua.Todo.Core/Entities/UserSessionEntity.cs @@ -0,0 +1,38 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 用户会话实体(服务端会话令牌)。 +/// +public class UserSessionEntity +{ + /// + /// 会话唯一标识符,同时作为 Bearer Token 值使用。 + /// + public Guid Id { get; set; } + + /// + /// 会话所属用户 ID。 + /// + public Guid UserId { get; set; } + + /// + /// 会话所属用户导航属性。 + /// + public UserEntity? User { get; set; } + + /// + /// 会话创建时间(UTC)。 + /// + public DateTime CreatedAtUtc { get; set; } + + /// + /// 会话过期时间(UTC)。 + /// + public DateTime ExpiresAtUtc { get; set; } + + /// + /// 二次认证(step-up)有效期截止(UTC);为空表示未进行二次认证。 + /// + public DateTime? StepUpExpiresAtUtc { get; set; } +} + diff --git a/src/Hua.Todo.Host/Program.cs b/src/Hua.Todo.Host/Program.cs index 2f98173..a3a48e7 100644 --- a/src/Hua.Todo.Host/Program.cs +++ b/src/Hua.Todo.Host/Program.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Hua.Todo.Application; +using Hua.Todo.Application.CloudSync; using Hua.Todo.Application.DynamicApi; using Hua.Todo.Application.Interfaces; using Hua.Todo.Application.Models; @@ -9,7 +10,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddAuthorization(); +builder.Services.AddCloudSyncServer(); builder.Services.AddApplicationServices("Data Source=Hua.Todo.db"); @@ -40,7 +41,9 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseCors("AllowAll"); +app.UseAuthentication(); app.UseAuthorization(); +app.MapCloudSyncEndpoints(); app.UseDynamicApi(); app.Run(); diff --git a/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj b/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj index f233df9..9e820d1 100644 --- a/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj +++ b/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj @@ -29,7 +29,7 @@ com.companyname.Hua.Todo.maui - 1.1.5 + 1.1.9 $(Version) 1 @@ -95,8 +95,8 @@ - - + + @@ -146,13 +146,19 @@ - + + + + + + + - + - + <_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" /> @@ -169,15 +175,16 @@ - + - <_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" /> + <_MauiWwwrootFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" /> - - - - + + + wwwroot/%(RecursiveDir)%(Filename)%(Extension) + + @@ -189,3 +196,7 @@ + + + + diff --git a/src/Hua.Todo.Maui/icon.jpg b/src/Hua.Todo.Maui/Resources/Images/icon.jpg similarity index 100% rename from src/Hua.Todo.Maui/icon.jpg rename to src/Hua.Todo.Maui/Resources/Images/icon.jpg diff --git a/src/Hua.Todo.Maui/appsettings.json b/src/Hua.Todo.Maui/appsettings.json index bac8595..842ac5f 100644 --- a/src/Hua.Todo.Maui/appsettings.json +++ b/src/Hua.Todo.Maui/appsettings.json @@ -1,7 +1,7 @@ { "WebServer": { "Port": 5057, - "IsUsingStatic": true, + "IsUsingStatic": false, "ConnectionString": "", "HostUrl": "http://localhost:5057", "ForEndUrl": "http://localhost:5174" diff --git a/src/Hua.Todo.Maui/setup.iss b/src/Hua.Todo.Maui/setup.iss index 35e0e26..9d58c2c 100644 --- a/src/Hua.Todo.Maui/setup.iss +++ b/src/Hua.Todo.Maui/setup.iss @@ -1,8 +1,8 @@ #define MyAppName "Hua.Todo" -#define MyAppVersion "1.1.4" +#define MyAppVersion "1.1.8" #define MyAppPublisher "ShaoHua" #define MyAppURL "https://git.we965.cn/Tools/Hua.Todo" -#define MyAppExeName "Hua.Todo.exe" +#define MyAppExeName "Hua.Todo.Maui.exe" [Setup] ; 1. 改用更快压缩(优先速度) diff --git a/src/Hua.Todo.Web/package.json b/src/Hua.Todo.Web/package.json index ee5dd60..b26c4bf 100644 --- a/src/Hua.Todo.Web/package.json +++ b/src/Hua.Todo.Web/package.json @@ -1,5 +1,5 @@ { - "name": "Hua.Todo-web", + "name": "Hua.Todo-Web", "private": true, "version": "0.0.0", "license": "AGPL-3.0", @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", + "build:maui": "vue-tsc -b && vite build --mode maui", "preview": "vite preview" }, "dependencies": { diff --git a/src/Hua.Todo.Web/src/App.vue b/src/Hua.Todo.Web/src/App.vue index a418d51..3b0654e 100644 --- a/src/Hua.Todo.Web/src/App.vue +++ b/src/Hua.Todo.Web/src/App.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue'; import TaskList from './components/TaskList.vue'; import HotKeySettingsDialog from './components/HotKeySettingsDialog.vue'; +import CloudSyncSettingsDialog from './components/CloudSyncSettingsDialog.vue'; const toastMessage = ref(''); const toastType = ref<'error' | 'success'>('error'); @@ -26,6 +27,7 @@ onMounted(() => {
+ diff --git a/src/Hua.Todo.Web/src/api/cloudClient.ts b/src/Hua.Todo.Web/src/api/cloudClient.ts new file mode 100644 index 0000000..684e854 --- /dev/null +++ b/src/Hua.Todo.Web/src/api/cloudClient.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import CloudSyncStorage from '../services/cloudSyncStorage'; + +const showNotification = (message: string, type: 'error' | 'success' = 'error') => { + if ((window as any).showToast) { + (window as any).showToast(message, type); + } else { + window.alert(message); + } +}; + +/** + * 云同步专用 HTTP Client。 + * + * 注意:本项目现有 `client.ts` 的 baseURL 固定为 `window.__API_BASE_URL__`(通常已包含 `/api`), + * 但云同步服务端端点位于根路径(`/auth/*`、`/tasks/*` 等)。 + * 因此云同步需要独立的 baseURL(用户配置的服务端地址)。 + */ +const cloudClient = axios.create({ + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +cloudClient.interceptors.request.use((config) => { + const { serverUrl } = CloudSyncStorage.loadSettings(); + if (serverUrl) { + config.baseURL = serverUrl; + } + + const session = CloudSyncStorage.loadSession(); + if (session?.accessToken) { + config.headers = config.headers ?? {}; + (config.headers as any).Authorization = `Bearer ${session.accessToken}`; + } + + return config; +}); + +cloudClient.interceptors.response.use( + (response) => response, + (error) => { + let errorMessage = '网络错误,请稍后再试'; + + if (error?.response?.data) { + const data = error.response.data; + if (typeof data === 'string' && data.trim()) { + errorMessage = data; + } else if (data && typeof data === 'object') { + const message = (data.message ?? data.Message) as unknown; + const errors = (data.errors ?? data.Errors) as unknown; + + if (Array.isArray(errors) && errors.length > 0) { + errorMessage = errors.join('\n'); + } else if (typeof message === 'string' && message.trim()) { + errorMessage = message; + } + } + } else if (typeof error?.message === 'string' && error.message.trim()) { + errorMessage = error.message; + } + + showNotification(errorMessage, 'error'); + return Promise.reject(error); + } +); + +export default cloudClient; diff --git a/src/Hua.Todo.Web/src/api/cloudSync.ts b/src/Hua.Todo.Web/src/api/cloudSync.ts new file mode 100644 index 0000000..668bef4 --- /dev/null +++ b/src/Hua.Todo.Web/src/api/cloudSync.ts @@ -0,0 +1,98 @@ +import cloudClient from './cloudClient'; +import CloudSyncStorage from '../services/cloudSyncStorage'; +import type { Task } from '../types/task'; + +export interface CloudLoginRequest { + userName: string; + password: string; +} + +export interface CloudLoginResponse { + accessToken: string; + expiresAtUtc: string; + userId: string; + role: string; + permissions: string[]; +} + +export interface CloudTaskItem { + id: number; + title: string; + priority: 0 | 1 | 2; + isCompleted: boolean; + createdAtUtc: string; + updatedAtUtc: string; + parentTaskId?: number | null; +} + +/** + * 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。 + */ +export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { + const map = new Map(); + for (const item of items) { + map.set(item.id, { + id: item.id, + title: item.title ?? '', + priority: item.priority ?? 1, + isCompleted: Boolean(item.isCompleted), + createdAt: item.createdAtUtc, + updatedAt: item.updatedAtUtc, + parentTaskId: item.parentTaskId ?? undefined, + subTasks: [], + }); + } + + const roots: Task[] = []; + for (const task of map.values()) { + const parentId = task.parentTaskId; + if (typeof parentId === 'number' && map.has(parentId)) { + map.get(parentId)!.subTasks.push(task); + } else { + roots.push(task); + } + } + + return roots; +}; + +/** + * 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。 + */ +export const cloudSyncApi = { + /** + * 登录云同步服务端并保存会话(默认存入 sessionStorage)。 + */ + async login(request: CloudLoginRequest): Promise { + const response = await cloudClient.post('/auth/login', request); + const session = response.data; + + CloudSyncStorage.saveSession({ + accessToken: session.accessToken, + expiresAtUtc: session.expiresAtUtc, + userId: session.userId, + role: session.role, + permissions: Array.isArray(session.permissions) ? session.permissions : [], + }); + + return session; + }, + + /** + * 清空云同步会话(登出)。 + */ + logout(): void { + CloudSyncStorage.clearSession(); + }, + + /** + * 获取云端任务全量,并转换为前端树结构。 + */ + async getTasks(): Promise { + const response = await cloudClient.get('/tasks/'); + const items = Array.isArray(response.data) ? response.data : []; + return buildTaskTreeFromCloudItems(items); + }, +}; + +export default cloudSyncApi; diff --git a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue new file mode 100644 index 0000000..59a54e6 --- /dev/null +++ b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue @@ -0,0 +1,534 @@ + + + + + diff --git a/src/Hua.Todo.Web/src/components/TaskItem.vue b/src/Hua.Todo.Web/src/components/TaskItem.vue index 98b1f16..6533457 100644 --- a/src/Hua.Todo.Web/src/components/TaskItem.vue +++ b/src/Hua.Todo.Web/src/components/TaskItem.vue @@ -4,13 +4,14 @@ -
- -