From 14868c45c718d15fad1e35611ebdda3994d53029 Mon Sep 17 00:00:00 2001 From: ShaoHua <345265198@qqcom> Date: Sun, 14 Jun 2026 04:51:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0v1.2.0=E4=BA=91?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=AE=9E=E4=BD=93=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重构用户与任务实体:UserEntity实现IUser,TaskEntity继承ABP风格FullAuditedEntityWithUser,主键从int改为Guid 2. 新增云同步代理系统:嵌入式WebServer支持CloudSyncProxy转发云同步请求,新增配置API与持久化 3. 完善前端适配:新增Guid工具函数,更新任务类型定义与API交互逻辑,调整云同步设置弹窗适配本地代理 4. 文档与配置优化:更新文档结构,新增部署文档、版本记录,统一各项目配置项 5. 补充测试与迁移:新增单元测试,更新EF Core数据库迁移快照 --- README.md | 12 +- .../{技术设计文档.md => 01-技术设计文档.md} | 10 +- docs/manual/02-任务同步规则.md | 512 ++++++++++++ .../{技术栈与模块.md => 03-技术栈与模块.md} | 0 .../{代码规范文档.md => 04-代码规范文档.md} | 0 docs/manual/{部署文档.md => 05-部署文档.md} | 0 docs/manual/{版本记录.md => 06-版本记录.md} | 8 +- docs/manual/{其他信息.md => 07-其他信息.md} | 0 docs/project/研发工单-v1.2.0/00-工单总览.md | 2 + .../09-CloudSync-同步策略改进方案.md | 734 ++++++++++++++++++ .../CloudSync/CloudSyncEndpointExtensions.cs | 34 + .../CloudSync/CloudSyncProxyMiddleware.cs | 116 +++ .../CloudSync/CloudSyncProxySettings.cs | 34 + .../CloudSyncProxySettingsPersistence.cs | 57 ++ .../CloudSyncServiceCollectionExtensions.cs | 24 + .../CloudSync/Models/TaskSyncDtos.cs | 156 ++-- .../Services/CloudTaskSyncService.cs | 364 ++++++--- .../Data/TodoDbContext.cs | 27 +- .../Interfaces/ITaskService.cs | 58 +- .../MakeTaskEntityAbpCompatible.Designer.cs | 299 +++++++ .../Migrations/MakeTaskEntityAbpCompatible.cs | 122 +++ .../Migrations/TodoDbContextModelSnapshot.cs | 51 +- src/Hua.Todo.Application/Models/TaskModels.cs | 35 +- .../Repositories/TaskRepository.cs | 35 +- .../Services/TaskService.cs | 119 +-- src/Hua.Todo.Avalonia/Models/AppSettings.cs | 7 + .../Services/EmbeddedWebServerService.cs | 17 + src/Hua.Todo.Avalonia/appsettings.json | 3 +- .../Entities/FullAuditedEntityWithUser.cs | 69 ++ src/Hua.Todo.Core/Entities/TaskEntity.cs | 55 +- src/Hua.Todo.Core/Entities/UserEntity.cs | 2 +- .../Interfaces/ITaskRepository.cs | 65 +- src/Hua.Todo.Maui/MauiProgram.cs | 1 + src/Hua.Todo.Maui/Models/AppSettings.cs | 7 + .../Services/EmbeddedWebServerService.cs | 17 + src/Hua.Todo.Maui/appsettings.json | 3 +- src/Hua.Todo.Tests/CloudSyncDtoTests.cs | 137 ++++ .../CloudTaskSyncServiceSqliteTests.cs | 420 ++++++++++ .../CloudTaskSyncServiceTests.cs | 678 ++++++++++++++++ src/Hua.Todo.Tests/Hua.Todo.Tests.csproj | 31 + src/Hua.Todo.Tests/TaskEntityTests.cs | 123 +++ src/Hua.Todo.Web/.env.maui | 3 +- src/Hua.Todo.Web/src/api/cloudClient.ts | 15 +- src/Hua.Todo.Web/src/api/cloudSync.ts | 87 ++- src/Hua.Todo.Web/src/api/tasks.ts | 39 +- .../components/CloudSyncSettingsDialog.vue | 51 +- src/Hua.Todo.Web/src/components/TaskItem.vue | 8 +- src/Hua.Todo.Web/src/components/TaskList.vue | 77 +- .../src/services/taskNormalizer.ts | 23 +- src/Hua.Todo.Web/src/types/task.ts | 51 +- src/Hua.Todo.Web/src/utils/guid.ts | 19 + 51 files changed, 4368 insertions(+), 449 deletions(-) rename docs/manual/{技术设计文档.md => 01-技术设计文档.md} (98%) create mode 100644 docs/manual/02-任务同步规则.md rename docs/manual/{技术栈与模块.md => 03-技术栈与模块.md} (100%) rename docs/manual/{代码规范文档.md => 04-代码规范文档.md} (100%) rename docs/manual/{部署文档.md => 05-部署文档.md} (100%) rename docs/manual/{版本记录.md => 06-版本记录.md} (91%) rename docs/manual/{其他信息.md => 07-其他信息.md} (100%) create mode 100644 docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md create mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs create mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs create mode 100644 src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs create mode 100644 src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs create mode 100644 src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs create mode 100644 src/Hua.Todo.Core/Entities/FullAuditedEntityWithUser.cs create mode 100644 src/Hua.Todo.Tests/CloudSyncDtoTests.cs create mode 100644 src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs create mode 100644 src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs create mode 100644 src/Hua.Todo.Tests/Hua.Todo.Tests.csproj create mode 100644 src/Hua.Todo.Tests/TaskEntityTests.cs create mode 100644 src/Hua.Todo.Web/src/utils/guid.ts diff --git a/README.md b/README.md index efeedc9..f35fa29 100644 --- a/README.md +++ b/README.md @@ -179,12 +179,12 @@ Hua.Todo/ ## 📚 更多文档 ### 用户与开发者手册 -- [技术栈与模块说明](docs/manual/技术栈与模块.md) -- [版本更新历史](docs/manual/版本记录.md) -- [技术设计文档](docs/manual/技术设计文档.md) -- [代码规范文档](docs/manual/代码规范文档.md) -- [部署文档](docs/manual/部署文档.md) -- [其他信息](docs/manual/其他信息.md) +- [技术栈与模块说明](docs/manual/03-技术栈与模块.md) +- [版本更新历史](docs/manual/06-版本记录.md) +- [技术设计文档](docs/manual/01-技术设计文档.md) +- [代码规范文档](docs/manual/04-代码规范文档.md) +- [部署文档](docs/manual/05-部署文档.md) +- [其他信息](docs/manual/07-其他信息.md) ### 项目进度与需求 - [产品需求文档](docs/project/产品需求文档.md) diff --git a/docs/manual/技术设计文档.md b/docs/manual/01-技术设计文档.md similarity index 98% rename from docs/manual/技术设计文档.md rename to docs/manual/01-技术设计文档.md index 45d60a2..ea97012 100644 --- a/docs/manual/技术设计文档.md +++ b/docs/manual/01-技术设计文档.md @@ -31,11 +31,11 @@ Hua.Todo/ ├── docs/ # 文档目录 │ ├── manual/ # 用户/开发者手册 -│ │ ├── 技术栈与模块.md -│ │ ├── 版本记录.md -│ │ ├── 技术设计文档.md(本文件) -│ │ ├── 代码规范文档.md -│ │ └── 部署文档.md +│ │ ├── 03-技术栈与模块.md +│ │ ├── 06-版本记录.md +│ │ ├── 01-技术设计文档.md(本文件) +│ │ ├── 04-代码规范文档.md +│ │ └── 05-部署文档.md │ └── project/ # 项目进度/需求文档 │ ├── 产品需求文档.md │ └── ... diff --git a/docs/manual/02-任务同步规则.md b/docs/manual/02-任务同步规则.md new file mode 100644 index 0000000..5e01a74 --- /dev/null +++ b/docs/manual/02-任务同步规则.md @@ -0,0 +1,512 @@ +# Todo 待办项云同步规则 + +> 本文档汇总 Hua.Todo 项目的 Todo 待办项云同步完整规则,涵盖架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等。 +> +> 术语:本文中"任务 / Todo 待办项"均为业务实体(对应代码 `Task` / `SubTask` / `TaskEntity`),与编码侧的"研发工单"无关。 + +--- + +## 一、部署架构与职责边界 + +### 1.1 两种运行模式 + +| 模式 | 宿主 | 云同步端点 | 说明 | +|---|---|---|---| +| **嵌入式** | MAUI / Avalonia | 不暴露 | 嵌入 Kestrel + WebView,仅注册 `AddApplicationServices()`,托管本地 `/api/*`;云同步需连接外部 Host | +| **独立服务端** | Hua.Todo.Host | 完整暴露 | 同时注册 `AddApplicationServices()` + `AddCloudSyncServer()`,对外提供全部云同步端点 | + +### 1.2 DI 注册边界(强制) + +``` +Hua.Todo.Application (共享层) +├── AddApplicationServices() ← Todo CRUD,两端均注册 +│ ├── TodoDbContext / TaskRepository / TaskService +│ └── DynamicApi (本地 /api/*) +│ +└── AddCloudSyncServer() ← 云同步能力,仅 Host 注册 + ├── CloudAuthService / CloudTaskSyncService + ├── SecurityPolicyService / CloudAdminService / CloudProbeService + ├── SessionAuthenticationHandler (Bearer Token 鉴权) + └── MapCloudSyncEndpoints() (13 个云同步端点) +``` + +- **MAUI / Avalonia**:`MauiProgram.cs` / `AvaloniaProgram.cs` **只能**调用 `AddApplicationServices()`,禁止调用 `AddCloudSyncServer()`。 +- **Hua.Todo.Host**:`Program.cs` 同时调用两者。 + +### 1.3 同源 Host 改造(研发工单 08) + +前端云同步请求统一走当前 Host 同源,不再直连外部 `serverUrl`: + +- **Host 模式(Vite dev)**:Vite proxy 将 `/auth`、`/tasks`、`/sync`、`/security`、`/cloud-sync` 转发到 Host(`:5173`)。 +- **MAUI 模式(WebView)**:Vue 静态部署到嵌入服务器同源,无需代理。 +- `cloudClient.ts` 不设外部 baseURL,由浏览器自动同源。 +- 用户配置的 `serverUrl` 仅用于服务端代理探测(`POST /cloud-sync/probe`),不再作为 API 基址。 + +--- + +## 二、数据隔离规则 + +### 2.1 UserId 隔离(强制) + +- `Tasks` 表含 `UserId` 字段(外键),所有云同步 API 按当前登录用户隔离读写。 +- 本地模式(嵌入式):`Tasks.UserId = TodoUserIds.LocalUserId = "local"`,与云端用户共存不冲突。 +- 服务端查询/写入必须从会话上下文提取 `UserId`,不得接受客户端传入的 `UserId` 参数。 + +### 2.2 同步数据范围 + +- 每次同步操作仅涉及**当前登录用户**的 Todo 待办项。 +- `GET /tasks` 返回该用户全量任务(`CloudTaskItem[]`)。 +- `POST /sync` 的 upsert/deletes 均限定在当前用户数据范围内执行。 + +--- + +## 三、认证与会话管理 + +### 3.1 认证方式 + +- **Bearer Token**:所有云同步端点需携带 `Authorization: Bearer {accessToken}`。 +- Token 即 `UserSessionEntity.SessionId`(GUID),存储于服务端 `UserSessions` 表,非纯无状态 JWT。 + +### 3.2 会话生命周期 + +| 操作 | 端点 | 说明 | +|---|---|---| +| 初始化管理员 | `POST /auth/bootstrap` | 仅当系统无云用户时可调用一次,自动生成随机密码 | +| 登录 | `POST /auth/login` | 验证用户名/密码(Argon2id 哈希),创建 `UserSessionEntity`,返回 AccessToken + 权限列表 | +| 登出 | `POST /auth/logout` | 删除当前会话记录,Token 立即失效 | +| 修改密码 | `POST /auth/change-password` | 需提供当前密码,更新后旧会话不失效 | + +### 3.3 SessionAuthenticationHandler 鉴权流程 + +1. 从 `Authorization` 头提取 Bearer Token(GUID 格式 SessionId) +2. 查 `UserSessions` 表:校验 SessionId 存在且 `ExpiresAtUtc > DateTime.UtcNow` +3. 加载用户角色,通过 `IRolePermissionMapper` 获取权限列表 +4. 检查 `SecurityPolicies.AllowSync`:若为 `false`,从权限中移除 `sync:write` +5. 构造 `ClaimsIdentity`(含 `sub`=UserId、`role`、`perm` Claims),注入 `HttpContext.User` + +--- + +## 四、RBAC 权限模型 + +### 4.1 权限点定义(6 个) + +| 权限点 | 常量 | 说明 | +|---|---|---| +| `tasks:read` | CloudPermissions.TasksRead | 读取 Todo 待办项 | +| `tasks:write` | CloudPermissions.TasksWrite | 写入 Todo 待办项 | +| `sync:write` | CloudPermissions.SyncWrite | 执行同步操作(受 `AllowSync` 策略叠加限制) | +| `policy:read` | CloudPermissions.PolicyRead | 读取安全策略 | +| `policy:write` | CloudPermissions.PolicyWrite | 修改安全策略 | +| `users:manage` | CloudPermissions.UsersManage | 管理用户(Admin 专属) | + +### 4.2 角色与权限映射 + +| 角色 | 权限 | +|---|---| +| `admin` | 全部 6 个权限 | +| `user`(默认) | `tasks:read`、`tasks:write`、`sync:write`、`policy:read` | +| `readonly` | `tasks:read`、`policy:read` | +| `nosync` | `tasks:read`、`tasks:write`、`policy:read` | + +> `sync:write` 权限在鉴权时还会受 `SecurityPolicies.AllowSync` 二次过滤:若策略禁止同步,即使角色拥有 `sync:write` 也会被移除。 + +### 4.3 端点权限要求 + +| 端点 | 方法 | 所需权限 | +|---|---|---| +| `/tasks` | GET | `tasks:read` | +| `/sync` | POST | `sync:write` | +| `/security/policy` | GET | `policy:read` | +| `/security/policy` | PUT | `policy:write` | +| `/admin/*` | GET/POST/DELETE | `users:manage` | +| `/cloud-sync/probe` | POST | —(匿名访问) | + +--- + +## 五、安全策略与可控落盘 + +### 5.1 SecurityPolicies 表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `AllowPersist` | bool | 是否允许客户端持久化 Todo 数据到本地存储 | +| `AllowSync` | bool | 是否允许该用户执行同步写入 | +| `IsTrustedDeviceOnly` | bool | 是否仅限受信任终端(预留) | + +每个用户一条策略记录,与 `UserEntity` 一对一。 + +### 5.2 客户端落盘规则(强制) + +| 服务端策略 | 客户端行为 | +|---|---| +| `allowPersist = true` | 正常落盘:Todo 数据 + 登录凭据(Token)写入 `localStorage` / SQLite | +| `allowPersist = false` | **内存模式**:Todo 数据与凭据仅保留在内存中,退出应用后全部清除 | + +**策略切换时的处理**: + +- `true → false`:立即清空已落盘数据,提示用户"已切换为内存模式,退出后数据不保留" +- `false → true`:恢复持久化,将当前内存数据写入存储 + +### 5.3 落盘数据范围 + +"禁止落盘"时,以下数据均不得写入任何持久化介质: + +- Todo 数据(列表/详情) +- 登录凭据(accessToken / 会话标识) +- 同步状态(上次同步时间、待同步队列) +- 安全策略缓存 + +--- + +## 六、同步工作流 + +### 6.1 完整流程 + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 配置地址 │ ──→ │ 登录 │ ──→ │ 拉取全量 │ +│ (探测可达) │ │ (获取Token) │ │ (GET /tasks) │ +└──────────────┘ └──────────────┘ └──────┬───────┘ + │ + ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 返回全量 │ ←── │ 增删改同步 │ ←── │ 本地编辑 │ +│ (SyncResponse)│ │ (POST /sync) │ │ (upsert+del)│ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +### 6.2 同步策略 + +#### 6.2.1 核心原则 + +- **任务唯一标识是 `id`**:`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断 +- **`updatedAtUtc` 是冲突判断字段**:用于解决"同一任务被多端修改时哪个变更更新"的问题 +- **服务端存储唯一真实源(Source of Truth)**:所有设备最终都收敛到服务端数据 +- **字段级 Last-Write-Wins(LWW)合并**:同一字段的多设备并发修改,以 `updatedAtUtc` 较新者为准 +- **逻辑删除(Tombstone)**:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致 +- **增量同步**:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量 + +#### 6.2.2 变更追踪机制 + +每个 `CloudTaskItem` 包含以下时间戳字段: + +| 字段 | 说明 | +|---|---| +| `createdAtUtc` | 创建时间(服务端分配,永不改变) | +| `updatedAtUtc` | 最后一次修改时间(每次字段变更时更新) | +| `deletedAtUtc` | 逻辑删除时间(为 `null` 表示未删除) | + +> `updatedAtUtc` 由**请求发起方**(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。 + +#### 6.2.3 冲突解决规则 + +当客户端提交的任务与服务端现有任务发生 `id` 冲突时,按以下规则处理: + +| 场景 | 解决规则 | +|---|---| +| 同一 `id`,客户端 `updatedAtUtc` **更新** | 服务端接受客户端版本,覆盖对应字段 | +| 同一 `id`,服务端 `updatedAtUtc` **更新** | 服务端拒绝客户端版本,保留服务端版本 | +| 同一 `id`,两者 `updatedAtUtc` **相同** | 服务端优先(保守策略) | + +**字段级合并示例**: +- 设备 A 修改标题,设备 B 修改截止日期 +- 服务端对两个字段分别取 `updatedAtUtc` 较新者,合并出最终结果 +- 两个设备的修改都被保留,不会互相覆盖 + +#### 6.2.4 父子任务处理 + +- **删除**:删除父任务时,其子任务一并标记为已删除(递归) +- **创建**:子任务的 `parentTaskId` 在上传时若为 `null`(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射 +- **重建父子关系**:若原父任务被删除后重建,子任务的 `parentTaskId` 引用仍指向原父 ID,不会自动指向新父 + +#### 6.2.5 Tombstone 保留策略 + +- `deletedAtUtc` 非 `null` 的任务在服务端保留至少 **30 天** +- 超过 30 天后由服务端垃圾收集(物理删除) +- 客户端同步时拉取全量(含 Tombstone),本地过滤不展示 `deletedAtUtc != null` 的任务 + +#### 6.2.6 服务端处理流程 + +`POST /sync` 服务端执行顺序: + +``` +1. 解析 upserts 和 deletes +2. 对 deletes 中的每个 id: + - 递归标记该任务及所有子任务 deletedAtUtc = now +3. 对 upserts 按 updatedAtUtc 降序排列(较新的先处理) +4. 对每个 upsert 任务: + a. 若 id 在服务端不存在 → 创建新记录(分配正向 id) + b. 若 id 存在但服务端 updatedAtUtc 更新 → 跳过(保留服务端) + c. 若 id 存在且客户端 updatedAtUtc >= 服务端 updatedAtUtc → 字段级合并更新 +5. 返回当前用户全量任务(含 Tombstone) +``` + +> 排序处理的目的:确保最新的变更优先被采纳。 + +#### 6.2.7 关于"服务端为准"的正确理解 + +"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是: + +1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器 +2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据 +3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地 + +由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。 + +真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。 + +### 6.3 SyncRequest / SyncResponse 结构 + +**请求**(`POST /sync`): +```json +{ + "upserts": [ + { "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null, "updatedAtUtc": "2026-04-06T17:00:00Z" }, + { "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null, "updatedAtUtc": "2026-04-06T17:30:00Z" } + ], + "deletes": [2, 3] +} +``` + +- `id` 为服务端已知 ID(`id > 0`)时执行更新;`id` 为 `null` / 0 / 负数时创建新记录 +- `updatedAtUtc` 必填,客户端在发起变更时写入本地时间(UTC) +- `deletes` 为待逻辑删除的任务 ID 数组 + +**响应**: +```json +{ + "serverTimeUtc": "2026-04-06T17:39:30.0281279Z", + "tasks": [ + { "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null, "createdAtUtc": "2026-04-01T00:00:00Z", "updatedAtUtc": "2026-04-06T17:00:00Z", "deletedAtUtc": null } + ] +} +``` + +- `tasks` 返回当前用户**全量数据**(含 `deletedAtUtc != null` 的 Tombstone) +- 客户端用响应全量覆盖本地数据,本地过滤不展示已删除任务 + +### 6.4 同步按钮调用规则 + +**触发入口**:主界面"同步"按钮(`TaskList.vue`),绑定 `syncNow()` 方法。 + +#### 6.4.1 前置检查 + +1. 检查 `isSyncing` 状态,防止并发重复调用 +2. 检查登录状态(未登录则提示先登录) + +#### 6.4.2 变更检测(增量同步核心) + +本地维护 `pendingUpserts` 和 `pendingDeletes` 两个集合: + +| 操作 | 记录时机 | 记录内容 | +|---|---|---| +| 新增任务 | 创建时 | `{ id: 临时负数, title, priority, ..., updatedAtUtc: 当前时间 }` | +| 修改任务 | 任何字段变更时 | `{ id, 全部字段, updatedAtUtc: 当前时间 }` | +| 删除任务 | 点击删除时 | `{ id }`(从 pendingUpserts 移除,加入 pendingDeletes)| + +> 每次用户操作后,更新本地 `updatedAtUtc`。使用 `seenIds` 集合去重,同一 `id` 只保留最新一条记录。 + +#### 6.4.3 数据准备 + +1. **合并 pendingUpserts**: + - 调用 `flattenTasksToCloudUpserts(localTasks, pendingUpserts)` 生成 upsert 列表 + - **ID 映射规则**: + - `id > 0`:保留原 ID(服务端已知,执行更新) + - `id <= 0` 或未设置:置为 `null`(服务端分配新 ID,执行创建) + - 按 `updatedAtUtc` 升序排列(较早的先发送,便于服务端 LWW 判断) + +2. **生成 deletes 列表**: + - 从 `pendingDeletes` 提取待删除 ID + - 去重:`deletes = [...new Set(pendingDeletes.map(d => d.id))]` + +#### 6.4.4 请求发送 + +- 端点:`POST /sync/` +- 请求体:`{ upserts: CloudTaskUpsert[], deletes: number[] }` +- 携带 `Authorization: Bearer {accessToken}` + +#### 6.4.5 响应处理 + +1. **解析全量响应**: + - 调用 `buildTaskTreeFromCloudItems(response.tasks)` 将扁平响应转为前端树结构 + - 过滤 `deletedAtUtc == null` 的任务用于展示 + +2. **更新本地状态**: + - `tasks.value = cloudTasks`(覆盖式更新,确保与服务端一致) + - `LocalStorageService.saveTasks(cloudTasks)` + - 清空 `pendingUpserts` 和 `pendingDeletes` + +3. **更新同步状态**: + - `lastSyncTime`:使用响应中的 `serverTimeUtc` + - `syncError = null` + +#### 6.4.6 错误处理 + +| 错误类型 | 处理策略 | +|---|---| +| 401 未授权 | 清除本地会话,弹出重新登录提示 | +| 403 禁止 | Toast 提示权限不足 | +| 网络错误 | 重试(指数退避,最多 3 次),仍失败则保留 pending 数据下次同步 | + +#### 6.4.7 完整流程图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ syncNow() 触发 │ +└─────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. 前置检查:isSyncing? 已登录? │ +└─────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 变更检测:pendingUpserts + pendingDeletes │ +│ - 遍历 tasks,收集 dirty 项(updatedAtUtc > lastSyncTime)│ +│ - 新增/删除操作直接追加到 pending │ +└─────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 数据准备 │ +│ - flattenTasksToCloudUpserts() │ +│ - seenIds 去重 │ +│ - 按 updatedAtUtc 排序 │ +└─────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. POST /sync { upserts, deletes } │ +└─────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 5. 响应处理 │ +│ - buildTaskTreeFromCloudItems() │ +│ - tasks.value = cloudTasks │ +│ - saveTasks() │ +│ - 清空 pendingUpserts / pendingDeletes │ +│ - lastSyncTime = serverTimeUtc │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、API 契约总览 + +### 7.1 通用约定 + +- Base URL:客户端同源请求,无需外部配置 +- 认证:`Authorization: Bearer {accessToken}` +- 统一错误响应: +```json +{ "code": "ERROR_CODE", "message": "Human readable message." } +``` + +### 7.2 错误码 + +| 错误码 | HTTP 状态 | 含义 | +|---|---|---| +| `UNAUTHORIZED` | 401 | 未登录或会话失效 | +| `FORBIDDEN` | 403 | 权限不足(RBAC 拒绝 / 策略拒绝) | +| `BAD_REQUEST` | 400 | 请求参数不合法 | +| `NOT_FOUND` | 404 | 资源不存在 | + +### 7.3 完整端点清单 + +| 路由 | 方法 | 权限 | 说明 | +|---|---|---|---| +| `/auth/bootstrap` | POST | 无(仅首次) | 初始化管理员账号 | +| `/auth/login` | POST | 匿名 | 登录,返回 AccessToken | +| `/auth/logout` | POST | 登录即可 | 登出,删除会话 | +| `/auth/change-password` | POST | 登录即可 | 修改当前用户密码 | +| `/tasks` | GET | `tasks:read` | 获取当前用户全量任务 | +| `/sync` | POST | `sync:write` | 上传 upsert + deletes,返回最新全量 | +| `/security/policy` | GET | `policy:read` | 获取当前用户安全策略 | +| `/security/policy` | PUT | `policy:write` | 修改当前用户安全策略 | +| `/cloud-sync/probe` | POST | 匿名 | 服务端代理探测目标 URL 可达性 | +| `/admin/users` | GET/POST/DELETE | `users:manage` | 用户管理 | +| `/admin/sessions` | GET/DELETE | `users:manage` | 会话管理 | +| `/admin/audit-logs` | GET | `users:manage` | 审计日志查询 | + +--- + +## 八、前端实现规则 + +### 8.1 cloudClient.ts(Axios 实例) + +- 与通用 `client.ts`(baseURL 含 `/api`)解耦,云同步专用。 +- **请求拦截器**:MAUI 模式下从 `CloudSyncStorage` 读取 `serverUrl`,Host 模式不设 baseURL 走同源;自动附加 `Bearer {accessToken}`。 +- **响应拦截器**: + - 401 → 清除本地会话,弹出重新登录提示。 + - 403 `FORBIDDEN` → Toast 提示权限不足。 + - 其他错误 → 统一 Toast 通知。 + +### 8.2 cloudSync.ts(API 封装) + +提供 `cloudSyncApi` 对象,6 个方法: + +| 方法 | 端点 | 核心逻辑 | +|---|---|---| +| `login()` | `/auth/login` | 登录后保存 Session 到 `CloudSyncStorage` | +| `logout()` | `/auth/logout` | 清除本地会话与缓存 | +| `getTasks()` | `/tasks` | 拉取全量 `CloudTaskItem[]`,通过 `buildTaskTreeFromCloudItems` 将扁平数据转为前端 Task 树 | +| `syncTasks()` | `/sync` | 通过 `flattenTasksToCloudUpserts` 将 Task 树扁平化为 upsert 列表,携带 deletes 提交 | +| `getSecurityPolicy()` | `/security/policy` | 获取策略,驱动落盘/内存模式切换 | +| `probeServerUrl()` | `/cloud-sync/probe` | 由服务端代理探测目标 URL | + +### 8.3 CloudSyncSettingsDialog.vue(UI 组件) + +功能区块: + +- **同步开关**:checkbox 控制,校验地址已保存方可开启。 +- **服务端地址**:输入 + 规范化 + 保存并探测(`probeServerUrl`)。 +- **登录区**:用户名/密码 → `login()` → 刷新策略与状态。 +- **已登录区**:会话摘要、连接状态、安全策略展示、登出 + 立即同步。 +- 通过 CustomEvent(`cloudSyncStateChanged` / `cloudSyncTasksRequested`)与其他组件通信。 + +--- + +## 九、服务端代理探测规则 + +由研发工单 08 引入,`POST /cloud-sync/probe` 由 Host 代理探测目标 URL: + +- 输入:`{ "targetUrl": "https://example.com" }` +- 输出:`{ "isReachable", "httpStatus", "isHttps", "title", "description", "type" }`(type: `success` / `warn` / `error`) +- 安全约束: + - 限制目标 URL 仅允许 HTTP/HTTPS 公网地址,禁止探测 localhost / 内网 IP(防 SSRF)。 + - 建议加频率限制(如每分钟 3 次)。 + +--- + +## 十、审计日志 + +所有关键安全事件写入 `AuditLogs` 表: + +- 登录成功 / 失败(含 IP、终端信息) +- 安全策略变更 +- 用户管理操作(创建 / 删除 / 重置密码) + +通过 `/admin/audit-logs` 端点查询,支持按时间、用户、事件类型过滤。 + +--- + +## 十一、约束与风险 + +| 约束 / 风险 | 缓解措施 | +|---|---| +| Token 吊销(会话非纯无状态) | `UserSessions` 表管理;登出即删记录,Token 立即失效 | +| HTTPS 强制 | 所有安全通信必须基于 TLS,防中间人攻击窃取 Token | +| 暴力破解 | `POST /auth/login` 建议加 Rate Limiting | +| MAUI 端不暴露云同步端点 | DI 注册边界严格执行:`AddCloudSyncServer()` 仅 Host 调用 | +| `allowPersist = false` 时数据丢失风险 | UI 强提示;退出时若未同步则二次确认 | +| 密码存储 | 使用 Argon2id 哈希(含 Salt),不存明文 | +| SSRF(探测端点) | `CloudProbeService` 限制目标为公网地址,禁止内网探测 | + +--- + +> **相关文档**: +> - PRD v1.2.0:[产品需求文档-1.2.0.md](../project/产品需求文档-1.2.0.md) +> - 研发工单总览:[00-工单总览.md](../project/研发工单-v1.2.0/00-工单总览.md) +> - 服务端基础能力:[04-CloudSync-服务端基础能力.md](../project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md) +> - 客户端工作流:[05-CloudSync-客户端配置与工作流.md](../project/研发工单-v1.2.0/05-CloudSync-客户端配置与工作流.md) +> - 安全与可控落盘:[06-CloudSync-安全与可控落盘.md](../project/研发工单-v1.2.0/06-CloudSync-安全与可控落盘.md) +> - 安全设计方案:[06.1-CloudSync-服务端安全设计方案.md](../project/研发工单-v1.2.0/06.1-CloudSync-服务端安全设计方案.md) +> - 同源 Host 重构:[08-cloud_sync_refactor_plan.md](../project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md) +> - 技术设计文档:[技术设计文档.md](./01-技术设计文档.md) diff --git a/docs/manual/技术栈与模块.md b/docs/manual/03-技术栈与模块.md similarity index 100% rename from docs/manual/技术栈与模块.md rename to docs/manual/03-技术栈与模块.md diff --git a/docs/manual/代码规范文档.md b/docs/manual/04-代码规范文档.md similarity index 100% rename from docs/manual/代码规范文档.md rename to docs/manual/04-代码规范文档.md diff --git a/docs/manual/部署文档.md b/docs/manual/05-部署文档.md similarity index 100% rename from docs/manual/部署文档.md rename to docs/manual/05-部署文档.md diff --git a/docs/manual/版本记录.md b/docs/manual/06-版本记录.md similarity index 91% rename from docs/manual/版本记录.md rename to docs/manual/06-版本记录.md index 123f212..8eb0590 100644 --- a/docs/manual/版本记录.md +++ b/docs/manual/06-版本记录.md @@ -9,6 +9,10 @@ - v1.1.0:MAUI + WebView 跨平台版本 - v1.2.0 (规划中):Linux 支持与增强功能 +### v1.2.8 (2026-06-14) + +- **文档**:新增 [任务同步规则.md](./02-任务同步规则.md),汇总 Todo 待办项云同步的架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等完整规则。 + ### v1.2.8 (2026-04-13) - **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。 @@ -35,8 +39,8 @@ - **Windows WebView2 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。 - **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。 - **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。 -- **文档与部署指南**:新增 `docs/manual/部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。 -- **用户文档完善**:在规划中新增了 `docs/manual/新手指南.md` 和 `docs/manual/用户指南.md`。 +- **文档与部署指南**:新增 `docs/manual/05-部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。 +- **用户文档完善**:在规划中新增了 `docs/manual/08-新手指南.md` 和 `docs/manual/09-用户指南.md`。 27→ 28→### v1.1.1 (2026-04-06) diff --git a/docs/manual/其他信息.md b/docs/manual/07-其他信息.md similarity index 100% rename from docs/manual/其他信息.md rename to docs/manual/07-其他信息.md diff --git a/docs/project/研发工单-v1.2.0/00-工单总览.md b/docs/project/研发工单-v1.2.0/00-工单总览.md index 9a4b9b9..8ac68b5 100644 --- a/docs/project/研发工单-v1.2.0/00-工单总览.md +++ b/docs/project/研发工单-v1.2.0/00-工单总览.md @@ -43,6 +43,8 @@ | 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址校验+保存探测、登录/登出、登录后拉取云端 Todo 数据并在主界面只读展示 | | 06 - 安全与落盘策略 | 未标注 | 待验证 | | | 07 - 文档与验收 | 未标注 | 待验证 | | +| 08 - 同源 Host 重构 | 已设计 | 待实现 | Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` | +| 09 - 同步策略改进 | 待实现 | 待验证 | 重构 TaskEntity 继承 ABP 基类(`FullAuditedEntityWithUser`),主键从 `int` 改为 `Guid`,新增 ABP 审计字段(ExtraProperties/ConcurrencyStamp/CreationTime/CreatorId/LastModificationTime/LastModifierId/IsDeleted/DeletionTime/DeleterId) | ## 交付判定(v1.2.0 Done Definition) diff --git a/docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md b/docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md new file mode 100644 index 0000000..389520d --- /dev/null +++ b/docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md @@ -0,0 +1,734 @@ +# CloudSync 同步策略改进方案 + +> 研发工单序号:09 +> 依赖:04-CloudSync-服务端基础能力、05-CloudSync-客户端配置与工作流 +> 状态:待实现 + +--- + +## 一、背景与问题 + +当前同步规则([02-任务同步规则.md](../../manual/02-任务同步规则.md) 6.2 节)存在以下问题: + +1. **TaskEntity 缺少 ABP 审计字段**:未继承 ABP 基类,缺少 `ExtraProperties`、`ConcurrencyStamp`、`CreationTime`、`CreatorId`、`LastModificationTime`、`LastModifierId` 等标准字段 +2. **主键类型不符合 ABP 规范**:ABP 要求主键为 `Guid`,当前为 `int` +3. **软删除字段缺失**:没有 `IsDeleted` 和 `DeletionTime` 字段,无法实现 Tombstone 逻辑删除 + +--- + +## 二、ABP 审计字段规范(必须遵守) + +根据 [表设计与字段命名规范.md](file:///d:/Codes/CodeSmith/CodeSmith/ShaoHua.CodeSmith.Abp/docs/2.表设计与字段命名规范.md),`FullAuditedEntityWithUser` 基类提供以下字段: + +| ABP 标准字段 | 含义 | 类型 | +|---|---|---| +| `Id` | 主键 | `Guid`(ABP 强制要求) | +| `ExtraProperties` | 扩展属性字典 | `ExtraPropertyDictionary` | +| `ConcurrencyStamp` | 并发戳,用于乐观并发控制 | `string?` | +| `CreationTime` | 创建时间 | `DateTime` | +| `CreatorId` | 创建人Id | `Guid?` | +| `LastModificationTime` | 最后修改时间 | `DateTime?` | +| `LastModifierId` | 最后修改人Id | `Guid?` | +| `IsDeleted` | 软删除标记 | `bool` | +| `DeletionTime` | 删除时间 | `DateTime?` | +| `DeleterId` | 删除人Id | `Guid?` | + +### 字段忽略规则(ShouldIgnoreList) + +以下字段在实体中**不生成任何属性**: + +| 字段名 | 理由 | +|---|---| +| `ExtraProperties` | ABP 框架扩展属性,基类已处理 | + +### 不可编辑字段(NotEnableEditList) + +以下字段在更新 DTO 中**不可编辑**:`IsDeleted`、`CreationTime`、`CreatorId`、`LastModificationTime`、`LastModifierId`、`DeletionTime`、`DeleterId` + +--- + +## 三、TaskEntity 重构方案 + +### 3.1 数据库表名(ABP 规范) + +> ABP 框架约定表名格式为 `T_{实体名}s`,例如实体 `TaskEntity` 对应表名 `T_Tasks`。 +> EF Core 迁移脚本中的 `table: "Tasks"` 为 DbContext 配置的表名,两者须保持一致。 + +### 3.2 继承关系变更 + +```csharp +// 原 +public class TaskEntity +{ + public int Id { get; set; } + // ... +} + +// 改后 +public class TaskEntity : FullAuditedEntityWithUser +{ + // Id、ExtraProperties、ConcurrencyStamp、CreationTime、CreatorId、 + // LastModificationTime、LastModifierId、IsDeleted、DeletionTime、DeleterId + // 均由基类提供 +} +``` + +### 3.2 业务字段保留 + +| 业务字段 | 类型 | 说明 | +|---|---|---| +| `UserId` | `Guid` | 任务所属用户(云端隔离),本地为 `"local"` | +| `Title` | `string` | 任务标题 | +| `Priority` | `TaskPriority` | 优先级枚举 | +| `IsCompleted` | `bool` | 是否完成 | +| `ParentTaskId` | `Guid?` | 父任务ID(外键) | + +> **ParentTaskId 类型变更**:从 `int?` 改为 `Guid?`,与主键类型一致。 + +### 3.3 导航属性 + +```csharp +public class TaskEntity : FullAuditedEntityWithUser +{ + public UserEntity? User { get; set; } + public TaskEntity? ParentTask { get; set; } + public List SubTasks { get; set; } = new(); +} +``` + +--- + +## 四、DTO 重构方案(与数据库保持一致) + +### 4.1 CloudTaskItem(C# → JSON 响应) + +```csharp +// src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs + +/// +/// 云同步任务条目(ABP 标准)。 +/// +public class CloudTaskItem +{ + /// + /// 任务 ID(服务端分配,Guid)。 + /// + public Guid Id { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 优先级。 + /// + public TaskPriority Priority { get; set; } + + /// + /// 是否完成。 + /// + public bool IsCompleted { get; set; } + + /// + /// 父任务 ID(Guid)。 + /// + public Guid? ParentTaskId { get; set; } + + // === ABP 审计字段 === + + /// + /// 创建时间。 + /// + public DateTime CreationTime { get; set; } + + /// + /// 创建人 ID。 + /// + public Guid? CreatorId { get; set; } + + /// + /// 最后修改时间(用于 LWW 冲突判断)。 + /// + public DateTime? LastModificationTime { get; set; } + + /// + /// 最后修改人 ID。 + /// + public Guid? LastModifierId { get; set; } + + /// + /// 软删除标记。 + /// + public bool IsDeleted { get; set; } + + /// + /// 删除时间。 + /// + public DateTime? DeletionTime { get; set; } + + /// + /// 删除人 ID。 + /// + public Guid? DeleterId { get; set; } +} +``` + +### 4.2 CloudTaskUpsert(C# → JSON 请求) + +```csharp +/// +/// 任务 Upsert DTO(客户端提交)。 +/// +public class CloudTaskUpsert +{ + /// + /// 任务 ID;为 null 表示新建。 + /// + public Guid? Id { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 优先级。 + /// + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + + /// + /// 是否完成。 + /// + public bool IsCompleted { get; set; } + + /// + /// 父任务 ID(可选)。 + /// + public Guid? ParentTaskId { get; set; } + + /// + /// 客户端发起变更时写入的时间戳(UTC),用于 LWW 冲突判断。 + /// + public DateTime? LastModificationTime { get; set; } +} +``` + +### 4.3 SyncRequest / SyncResponse + +```csharp +/// +/// 同步请求(增改删)。 +/// +public class SyncRequest +{ + /// + /// 新增或更新的任务列表。 + /// + public List Upserts { get; set; } = new(); + + /// + /// 需要逻辑删除的任务 ID 列表。 + /// + public List Deletes { get; set; } = new(); +} + +/// +/// 同步响应。 +/// +public class SyncResponse +{ + /// + /// 服务端时间(UTC)。 + /// + public DateTime ServerTimeUtc { get; set; } + + /// + /// 当前用户的任务全量(含 Tombstone)。 + /// + public List Tasks { get; set; } = new(); +} +``` + +### 4.4 JSON 序列化配置 + +DTO 默认使用 PascalCase(ABP 标准),通过 `[JsonPropertyName]` 特性控制 JSON 输出: + +```csharp +public class CloudTaskItem +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + // ... 其他字段 +} +``` + +或通过全局配置启用 camelCase: + +```csharp +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); +``` + +--- + +## 五、前端类型定义(与 DTO 保持一致) + +### 5.1 当前状态 vs 目标状态对比 + +| 字段 | 当前类型 | 目标类型 | 变更说明 | +|---|---|---|---| +| `id` | `number` | `string` | 主键从 int 改为 Guid,JSON 序列化为字符串 | +| `parentTaskId` | `number \| undefined` | `string \| null` | 外键类型同步变更 | +| `createdAt` | `string` | **移除** | 替换为 `creationTime` | +| `updatedAt` | `string` | **移除** | 替换为 `lastModificationTime` | +| `subTasks` | `Task[]` | `Task[]` | 类型不变,递归结构 | +| — | — | `creationTime: string` | 新增:ABP 创建时间 | +| — | — | `creatorId: string \| null` | 新增:创建人 ID | +| — | — | `lastModificationTime: string \| null` | 新增:最后修改时间(LWW 冲突判断) | +| — | — | `lastModifierId: string \| null` | 新增:最后修改人 ID | +| — | — | `isDeleted: boolean` | 新增:软删除标记 | +| — | — | `deletionTime: string \| null` | 新增:删除时间 | +| — | — | `deleterId: string \| null` | 新增:删除人 ID | + +### 5.2 task.ts 目标定义 + +```typescript +// src/Hua.Todo.Web/src/types/task.ts + +export type TaskPriority = 0 | 1 | 2; + +/** + * Todo 待办项(与 CloudTaskItem DTO 一一对应)。 + * - 主键 id 为 Guid 序列化后的字符串 + * - 包含 ABP 审计字段,用于云同步冲突判断 + */ +export interface Task { + // === 业务字段 === + id: string; // Guid 序列化 + title: string; + priority: TaskPriority; + isCompleted: boolean; + parentTaskId: string | null; // Guid 类型,null 表示顶级任务 + + // === ABP 审计字段 === + /** 创建时间(服务端分配) */ + creationTime: string; + /** 创建人 ID */ + creatorId: string | null; + /** 最后修改时间(用于 LWW 冲突判断) */ + lastModificationTime: string | null; + /** 最后修改人 ID */ + lastModifierId: string | null; + /** 软删除标记(前端本地过滤不展示) */ + isDeleted: boolean; + /** 删除时间(不为 null 时表示已逻辑删除) */ + deletionTime: string | null; + /** 删除人 ID */ + deleterId: string | null; + + // === 导航属性 === + subTasks: Task[]; +} + +/** + * 创建 Todo 待办项请求(客户端 → 服务端,新建时不带 id) + */ +export interface CreateTaskDto { + title: string; + priority: TaskPriority; + parentTaskId?: string; // Guid 字符串 +} + +/** + * 更新 Todo 待办项请求(客户端 → 服务端,必须带 id) + */ +export interface UpdateTaskDto { + id: string; // Guid 字符串 + title?: string; + priority?: TaskPriority; + isCompleted?: boolean; +} +``` + +### 5.3 前端变更检测逻辑 + +```typescript +// src/Hua.Todo.Web/src/types/task.ts 或业务层 + +/** + * 标记任务为脏(本地修改待同步)。 + * - 每次用户操作(创建/修改/删除)时调用 + * - 更新 lastModificationTime 为当前 UTC 时间 + */ +export function markTaskDirty(task: Task): void { + task.lastModificationTime = new Date().toISOString(); + task.lastModifierId = getCurrentUserId(); // 若已登录 + pendingUpserts.set(task.id, task); +} + +/** + * 标记任务为待删除(本地删除待同步)。 + * - 设置 deletionTime 而非真正从数组移除 + */ +export function markTaskDeleted(task: Task): void { + task.isDeleted = true; + task.deletionTime = new Date().toISOString(); + task.deleterId = getCurrentUserId(); + pendingDeletes.add(task.id); +} +``` + +### 5.4 API 响应处理 + +```typescript +// src/Hua.Todo.Web/src/api/cloudSync.ts + +import type { Task } from '@/types/task'; + +/** + * 获取云端全量任务(含 Tombstone)。 + * 返回数据直接赋值给本地 store,无需字段转换。 + */ +export async function fetchCloudTasks(): Promise { + const response = await cloudSyncApi.getTasks(); + // response.data 类型已是 Task[],服务端返回的 Guid 已序列化为 string + return response.data; +} + +/** + * 拉取全量后,本地过滤不展示已删除任务。 + */ +export function filterVisibleTasks(tasks: Task[]): Task[] { + return tasks.filter(task => task.deletionTime === null); +} +``` + +### 5.5 pendingUpserts / pendingDeletes 类型修正 + +```typescript +// src/Hua.Todo.Web/src/stores/taskStore.ts(示例) + +import type { Task } from '@/types/task'; + +// 待上传的脏任务(key: task.id,即 Guid string) +const pendingUpserts = reactive(new Map()); + +// 待上传的删除任务 ID(Guid string) +const pendingDeletes = reactive(new Set()); +``` + +### 5.6 Guid 字符串处理工具 + +```typescript +// src/Hua.Todo.Web/src/utils/guid.ts + +/** + * 生成新的 Guid 字符串(客户端创建临时任务时使用)。 + * 格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx(小写) + */ +export function generateGuid(): string { + return crypto.randomUUID(); +} + +/** + * 判断字符串是否为有效 Guid 格式。 + */ +export function isValidGuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} +``` + +--- + +## 六、迁移脚本 + +### 6.1 EF Core 迁移命令 + +```powershell +cd src/Hua.Todo.Application +dotnet ef migrations add MakeTaskEntityAbpCompatible --startup-project ../Hua.Todo.Host +``` + +### 6.2 迁移内容预期 + +此迁移涉及大量字段变更,建议分阶段执行或使用"双写/兼容期"策略: + +```csharp +// MakeTaskEntityAbpCompatible.cs +// 表名:T_Tasks(ABP 规范),DbContext 配置为 "Tasks" + +protected override void Up(MigrationBuilder migrationBuilder) +{ + // 1. 新增 Guid Id 列(临时名) + migrationBuilder.AddColumn( + name: "NewId", + table: "T_Tasks", + type: "TEXT", + nullable: false, + defaultValue: Guid.NewGuid()); + + // 2. 将旧 int Id 数据迁移到 NewId + // (需要手动 SQL 脚本或数据迁移) + + // 3. 删除旧 Id 列,重命名 NewId 为 Id + migrationBuilder.DropColumn("Id", "T_Tasks"); + migrationBuilder.RenameColumn("NewId", "T_Tasks", "Id"); + + // 4. 新增 ABP 审计字段 + migrationBuilder.AddColumn( + name: "ExtraProperties", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ConcurrencyStamp", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreationTime", + table: "T_Tasks", + type: "TEXT", + nullable: false, + defaultValue: DateTime.UtcNow); + + migrationBuilder.AddColumn( + name: "CreatorId", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastModificationTime", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastModifierId", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "T_Tasks", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletionTime", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeleterId", + table: "T_Tasks", + type: "TEXT", + nullable: true); + + // 5. ParentTaskId 从 int 改为 Guid + migrationBuilder.DropColumn("ParentTaskId", "T_Tasks"); + migrationBuilder.AddColumn( + name: "ParentTaskId", + table: "T_Tasks", + type: "TEXT", + nullable: true); +} + +protected override void Down(MigrationBuilder migrationBuilder) +{ + // 回滚逻辑... +} +``` + +> **注意**:此迁移为破坏性变更,建议在离线环境充分测试后再部署到生产环境。 + +--- + +## 七、同步策略(与 02-任务同步规则.md 保持一致) + +> 本章节同步策略逻辑完全遵循 [02-任务同步规则.md](../../manual/02-任务同步规则.md) 6.2 节,字段名使用 ABP 标准。 + +### 7.1 核心原则 + +- **任务唯一标识是 `id`**:`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断 +- **`lastModificationTime` 是冲突判断字段**:用于解决"同一任务被多端修改时哪个变更更新"的问题 +- **服务端存储唯一真实源(Source of Truth)**:所有设备最终都收敛到服务端数据 +- **字段级 Last-Write-Wins(LWW)合并**:同一字段的多设备并发修改,以 `lastModificationTime` 较新者为准 +- **逻辑删除(Tombstone)**:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致 +- **增量同步**:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量 + +### 7.2 变更追踪机制 + +每个 `CloudTaskItem` 包含以下时间戳字段: + +| ABP 字段 | 说明 | +|---|---| +| `creationTime` | 创建时间(服务端分配,永不改变) | +| `lastModificationTime` | 最后一次修改时间(每次字段变更时更新) | +| `isDeleted` | 软删除标记 | +| `deletionTime` | 逻辑删除时间(为 `null` 表示未删除) | +| `creatorId` | 创建人 ID | +| `lastModifierId` | 最后修改人 ID | + +> `lastModificationTime` 由**请求发起方**(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。 + +### 7.3 冲突解决规则 + +当客户端提交的任务与服务端现有任务发生 `id` 冲突时,按以下规则处理: + +| 场景 | 解决规则 | +|---|---| +| 同一 `id`,客户端 `lastModificationTime` **更新** | 服务端接受客户端版本,覆盖对应字段 | +| 同一 `id`,服务端 `lastModificationTime` **更新** | 服务端拒绝客户端版本,保留服务端版本 | +| 同一 `id`,两者 `lastModificationTime` **相同** | 服务端优先(保守策略) | + +**字段级合并示例**: +- 设备 A 修改标题,设备 B 修改优先级 +- 服务端对两个字段分别取 `lastModificationTime` 较新者,合并出最终结果 +- 两个设备的修改都被保留,不会互相覆盖 + +### 7.4 父子任务处理 + +- **删除**:删除父任务时,其子任务一并标记为已删除(递归) +- **创建**:子任务的 `parentTaskId` 在上传时若为 `null`(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射 +- **重建父子关系**:若原父任务被删除后重建,子任务的 `parentTaskId` 引用仍指向原父 ID,不会自动指向新父 + +### 7.5 Tombstone 保留策略 + +- `deletionTime` 非 `null` 的任务在服务端保留至少 **30 天** +- 超过 30 天后由服务端垃圾收集(物理删除) +- 客户端同步时拉取全量(含 Tombstone),本地过滤不展示 `deletionTime != null` 的任务 + +### 7.6 服务端处理流程 + +`POST /sync` 服务端执行顺序: + +``` +1. 解析 upserts 和 deletes +2. 对 deletes 中的每个 id: + - 递归标记该任务及所有子任务 deletionTime = now, isDeleted = true +3. 对 upserts 按 lastModificationTime 降序排列(较新的先处理) +4. 对每个 upsert 任务: + a. 若 id 在服务端不存在 → 创建新记录(分配 Guid) + b. 若 id 存在但服务端 lastModificationTime 更新 → 跳过(保留服务端) + c. 若 id 存在且客户端 lastModificationTime >= 服务端 lastModificationTime → 字段级合并更新 +5. 返回当前用户全量任务(含 Tombstone) +``` + +> 排序处理的目的:确保最新的变更优先被采纳。 + +### 7.7 关于"服务端为准"的正确理解 + +"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是: + +1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器 +2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据 +3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地 + +由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。 + +真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。 + +--- + +## 九、服务端代码改动 + +### 9.1 TaskRepository 查询过滤 + +```csharp +// 基类已自动过滤 IsDeleted = true 的记录 +// 但若需要显式查询(含已删除),使用 _context.Tasks.IgnoreQueryFilters() +``` + +### 9.2 CloudTaskSyncService 逻辑删除处理 + +```csharp +// POST /sync 处理 deletes +foreach (var id in request.Deletes) +{ + var task = await _context.Tasks.FindAsync(id); + if (task != null && !task.IsDeleted) + { + await _taskRepository.DeleteAsync(task); // 调用基类软删除 + } +} +``` + +--- + +## 十、验收标准 + +1. [ ] TaskEntity 继承 `FullAuditedEntityWithUser` +2. [ ] 主键类型从 `int` 迁移到 `Guid` +3. [ ] EF Core 迁移成功执行,新增 ABP 审计字段 +4. [ ] 服务端 `/tasks` 端点返回 ABP 标准字段 +5. [ ] 服务端 `/sync` 端点正确处理软删除 +6. [ ] 前端正确适配 Guid 类型和 ABP 字段 +7. [ ] 同步规则文档(02-任务同步规则.md)字段命名已修正 +8. [ ] 数据模型约束文档(03-数据模型与迁移约束.md)已更新 + +--- + +## 十一、风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| 主键从 int 变 Guid 为破坏性迁移 | 使用双写/兼容期策略;提供数据迁移脚本 | +| 迁移破坏嵌入式宿主启动 | 在 MAUI/Avalonia 上验证 `Database.Migrate()` 不报错 | +| 前端类型变更导致编译错误 | 同步更新 TypeScript 类型定义 | +| 旧客户端不兼容新 API | API 版本化;新字段为可选/默认值 | + +**回滚方案**: +- 迁移回滚:`dotnet ef migrations remove`(可能丢失数据) +- 代码回滚:恢复 TaskEntity 原定义 + +--- + +## 十二、Touch List + +| 文件路径 | 改动类型 | 共享文件 | +|---|---|---| +| `src/Hua.Todo.Core/Entities/TaskEntity.cs` | 重构(继承 ABP 基类) | 否 | +| `src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs` | DTO 字段更新(新增 ABP 审计字段) | 否 | +| `src/Hua.Todo.Application/Data/TodoDbContext.cs` | 查询过滤器配置 | 否 | +| `src/Hua.Todo.Application/CloudSync/CloudTaskSyncService.cs` | 软删除处理逻辑 | 否 | +| `src/Hua.Todo.Application/Migrations/` | 新增迁移文件 | 否 | +| `src/Hua.Todo.Web/src/types/task.ts` | **重构(Guid 主键 + ABP 审计字段 + 类型修正)** | **前端共享** | +| `src/Hua.Todo.Web/src/api/cloudSync.ts` | API 响应类型对齐 | 否 | +| `src/Hua.Todo.Web/src/stores/taskStore.ts` | pendingUpserts/pendingDeletes 类型修正 | 否 | +| `src/Hua.Todo.Web/src/utils/guid.ts` | **新增 Guid 工具函数** | 否 | +| `docs/manual/02-任务同步规则.md` | 文档修正(字段命名) | 是(文档) | +| `.trae/rules/项目/03-数据模型与迁移约束.md` | 实体清单更新 | 是(规则) | + +--- + +## 十三、与其他工单的边界 + +| 工单 | 边界 | +|---|---| +| 04-CloudSync-服务端基础能力 | 依赖本工单的 ABP 审计字段 | +| 05-CloudSync-客户端配置与工作流 | 依赖本工单的 DTO 字段更新 | +| 08-同源 Host 重构 | 无依赖,可并行 | + +> **建议**:本工单与 04/05 存在依赖关系,建议在 04/05 之前完成,或合并为一个工单实现。 + +--- + +> **相关文档**: +> - ABP 表设计规范:[2.表设计与字段命名规范.md](file:///d:/Codes/CodeSmith/CodeSmith/ShaoHua.CodeSmith.Abp/docs/2.表设计与字段命名规范.md) +> - 同步规则:[02-任务同步规则.md](../../manual/02-任务同步规则.md) +> - 数据模型约束:[03-数据模型与迁移约束.md](../../../.trae/rules/项目/03-数据模型与迁移约束.md) \ No newline at end of file diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs index a359df4..e4e1d0d 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; using Hua.Todo.Application.CloudSync.Auth; using Hua.Todo.Application.CloudSync.Models; using Hua.Todo.Application.CloudSync.Services; @@ -342,4 +344,36 @@ public static class CloudSyncEndpointExtensions } #endregion + + /// + /// 注册云同步代理设置 API(仅嵌入式 WebServer 使用)。 + /// GET /api/cloud-sync/settings → 返回当前 URL + /// POST /api/cloud-sync/settings → 设置 URL + /// + public static WebApplication MapCloudSyncProxySettings(this WebApplication app) + { + var group = app.MapGroup("/api/cloud-sync").WithTags("CloudSync - Proxy"); + + group.MapGet("/settings", (CloudSyncProxySettings settings) => + { + return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty }); + }); + + group.MapPost("/settings", async (HttpContext context, CloudSyncProxySettings settings) => + { + using var doc = await JsonDocument.ParseAsync(context.Request.Body); + var serverUrl = doc.RootElement.TryGetProperty("serverUrl", out var el) ? el.GetString() : null; + settings.SetUrl(serverUrl); + + // 持久化到 appsettings.json + if (context.RequestServices.GetService() is { } persistence) + { + try { persistence.Save(settings.GetUrl() ?? string.Empty); } catch { /* 保存失败不影响前端 */ } + } + + return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty }); + }); + + return app; + } } diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs new file mode 100644 index 0000000..b3f8a69 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncProxyMiddleware.cs @@ -0,0 +1,116 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Hua.Todo.Application.CloudSync; + +/// +/// 云同步反向代理中间件。 +/// 将 /auth/、/tasks/、/sync/、/security/、/cloud-sync/ 请求转发到远程 Host。 +/// 仅在嵌入式 MAUI/Avalonia WebServer 中注册。 +/// +public class CloudSyncProxyMiddleware +{ + private readonly RequestDelegate _next; + private readonly IHttpClientFactory _httpClientFactory; + + /// + /// 需要代理的路径前缀集合。 + /// + private static readonly string[] ProxyPrefixes = { "/auth/", "/tasks/", "/sync/", "/security/", "/cloud-sync/" }; + + public CloudSyncProxyMiddleware(RequestDelegate next, IHttpClientFactory httpClientFactory) + { + _next = next; + _httpClientFactory = httpClientFactory; + } + + /// + /// 处理请求。如果匹配代理前缀则转发,否则放行到下一中间件。 + /// + public async Task InvokeAsync(HttpContext context) + { + var requestPath = context.Request.Path.Value ?? string.Empty; + + // 仅拦截云同步相关路径 + if (!ProxyPrefixes.Any(p => requestPath.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + await _next(context); + return; + } + + var settings = context.RequestServices.GetRequiredService(); + var targetUrl = settings.GetUrl(); + + if (string.IsNullOrEmpty(targetUrl)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("{\"error\":\"cloud sync server URL not configured\"}"); + return; + } + + await ProxyRequestAsync(context, targetUrl); + } + + /// + /// 将当前请求转发到目标地址,并回写响应。 + /// + private async Task ProxyRequestAsync(HttpContext context, string targetUrl) + { + var client = _httpClientFactory.CreateClient("CloudSyncProxy"); + client.Timeout = TimeSpan.FromSeconds(30); + + var request = context.Request; + var targetUri = $"{targetUrl}{request.Path}{request.QueryString}"; + + var proxyRequest = new HttpRequestMessage(new HttpMethod(request.Method), targetUri); + + // 复制请求体 + if (request.ContentLength > 0 || request.Headers.ContainsKey("Transfer-Encoding")) + { + request.EnableBuffering(); + request.Body.Position = 0; + using var reader = new StreamReader(request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + request.Body.Position = 0; + proxyRequest.Content = new StringContent(body, System.Text.Encoding.UTF8, request.ContentType ?? "application/json"); + } + + // 复制 Authorization 头 + if (request.Headers.TryGetValue("Authorization", out var authHeader)) + { + proxyRequest.Headers.TryAddWithoutValidation("Authorization", authHeader.ToArray()); + } + + // 复制 Content-Type(StringContent 已设置,但确保不被覆盖) + if (request.Headers.TryGetValue("Content-Type", out var ctHeader) && proxyRequest.Content != null) + { + proxyRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ctHeader.First()!); + } + + using var response = await client.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); + + context.Response.StatusCode = (int)response.StatusCode; + context.Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"; + + // 回写响应体 + var responseBody = await response.Content.ReadAsStringAsync(context.RequestAborted); + await context.Response.WriteAsync(responseBody, context.RequestAborted); + } +} + +/// +/// CloudSyncProxyMiddleware 的 IApplicationBuilder 扩展。 +/// +public static class CloudSyncProxyMiddlewareExtensions +{ + /// + /// 注册云同步反向代理中间件。 + /// + public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs new file mode 100644 index 0000000..23d673a --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettings.cs @@ -0,0 +1,34 @@ +namespace Hua.Todo.Application.CloudSync; + +/// +/// 云同步代理 URL 管理服务。 +/// 嵌入式 WebServer 启动时从 appsettings 读取初始值, +/// 前端通过 /api/cloud-sync/settings 读写。 +/// +public class CloudSyncProxySettings +{ + private string? _url; + private readonly object _lock = new(); + + /// + /// 获取当前配置的云同步服务端地址,未配置时返回 null。 + /// + public string? GetUrl() + { + lock (_lock) + { + return _url; + } + } + + /// + /// 设置云同步服务端地址。 + /// + public void SetUrl(string? url) + { + lock (_lock) + { + _url = string.IsNullOrWhiteSpace(url) ? null : url.TrimEnd('/'); + } + } +} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs new file mode 100644 index 0000000..db0396d --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncProxySettingsPersistence.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace Hua.Todo.Application.CloudSync; + +/// +/// 云同步代理 URL 的 appsettings.json 持久化帮助类。 +/// 由 MAUI/Avalonia 宿主创建并注册,注入 appsettings 文件路径。 +/// +public class CloudSyncProxySettingsPersistence +{ + private readonly string _settingsFilePath; + + public CloudSyncProxySettingsPersistence(string settingsFilePath) + { + _settingsFilePath = settingsFilePath; + } + + /// + /// 将 CloudSyncUrl 写回 appsettings.json。 + /// + public void Save(string url) + { + var json = File.ReadAllText(_settingsFilePath); + using var doc = JsonDocument.Parse(json); + using var stream = new FileStream(_settingsFilePath, FileMode.Create, FileAccess.Write); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + + writer.WriteStartObject(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.NameEquals("WebServer")) + { + writer.WritePropertyName("WebServer"); + writer.WriteStartObject(); + foreach (var wsProp in prop.Value.EnumerateObject()) + { + writer.WritePropertyName(wsProp.Name); + if (wsProp.NameEquals("CloudSyncUrl")) + { + writer.WriteStringValue(url ?? string.Empty); + } + else + { + wsProp.Value.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + else + { + writer.WritePropertyName(prop.Name); + prop.Value.WriteTo(writer); + } + } + writer.WriteEndObject(); + } +} diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs index e862087..4409ade 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs @@ -56,5 +56,29 @@ public static class CloudSyncServiceCollectionExtensions return services; } + + /// + /// 注册云同步反向代理所需的服务(轻量级,供嵌入式 MAUI/Avalonia WebServer 使用)。 + /// 仅注册 URL 管理服务与转发专用 HttpClient,不包含认证/授权/业务服务。 + /// + /// 服务集合。 + /// 服务集合。 + public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services) + { + services.AddSingleton(); + + // 代理专用的 HttpClient:不跟随重定向,允许转发到自签/内网服务器 + services.AddHttpClient("CloudSyncProxy", client => + { + client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false, + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + + return services; + } } diff --git a/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs index 373be01..83142d0 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs @@ -1,46 +1,140 @@ +using System.Text.Json.Serialization; using Hua.Todo.Core.Entities; namespace Hua.Todo.Application.CloudSync.Models; /// -/// 云同步任务条目。 +/// 云同步任务条目(ABP 标准)。 /// public class CloudTaskItem { /// - /// 任务 ID(服务端分配)。 + /// 任务 ID(服务端分配,Guid)。 /// - public int Id { get; set; } + [JsonPropertyName("id")] + public Guid Id { get; set; } /// /// 标题。 /// + [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; /// /// 优先级。 /// + [JsonPropertyName("priority")] public TaskPriority Priority { get; set; } /// /// 是否完成。 /// + [JsonPropertyName("isCompleted")] public bool IsCompleted { get; set; } /// - /// 创建时间(UTC)。 + /// 任务编号(用户级自增字符串)。 /// - public DateTime CreatedAtUtc { get; set; } + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; /// - /// 更新时间(UTC)。 + /// 父任务 ID(Guid)。 /// - public DateTime UpdatedAtUtc { get; set; } + [JsonPropertyName("parentTaskId")] + public Guid? ParentTaskId { get; set; } + + // === ABP 审计字段 === /// - /// 父任务 ID(v1.2.0 同步可先不使用)。 + /// 创建时间。 /// - public int? ParentTaskId { get; set; } + [JsonPropertyName("creationTime")] + public DateTime CreationTime { get; set; } + + /// + /// 创建人 ID。 + /// + [JsonPropertyName("creatorId")] + public Guid? CreatorId { get; set; } + + /// + /// 最后修改时间(用于 LWW 冲突判断)。 + /// + [JsonPropertyName("lastModificationTime")] + public DateTime? LastModificationTime { get; set; } + + /// + /// 最后修改人 ID。 + /// + [JsonPropertyName("lastModifierId")] + public Guid? LastModifierId { get; set; } + + /// + /// 软删除标记。 + /// + [JsonPropertyName("isDeleted")] + public bool IsDeleted { get; set; } + + /// + /// 删除时间。 + /// + [JsonPropertyName("deletionTime")] + public DateTime? DeletionTime { get; set; } + + /// + /// 删除人 ID。 + /// + [JsonPropertyName("deleterId")] + public Guid? DeleterId { get; set; } +} + +/// +/// 任务 Upsert DTO(客户端提交)。 +/// +public class CloudTaskUpsert +{ + /// + /// 任务 ID;为 null 表示新建。 + /// + [JsonPropertyName("id")] + public Guid? Id { get; set; } + + /// + /// 标题。 + /// + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// + /// 优先级。 + /// + [JsonPropertyName("priority")] + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + + /// + /// 是否完成。 + /// + [JsonPropertyName("isCompleted")] + public bool IsCompleted { get; set; } + + /// + /// 任务编号。 + /// + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + /// + /// 父任务 ID(可选)。 + /// + [JsonPropertyName("parentTaskId")] + public Guid? ParentTaskId { get; set; } + + /// + /// 客户端发起变更时写入的时间戳(UTC),用于 LWW 冲突判断。 + /// + [JsonPropertyName("lastModificationTime")] + public DateTime? LastModificationTime { get; set; } } /// @@ -51,43 +145,14 @@ public class SyncRequest /// /// 新增或更新的任务列表。 /// + [JsonPropertyName("upserts")] public List Upserts { get; set; } = new(); /// - /// 需要删除的任务 ID 列表。 + /// 需要逻辑删除的任务 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; } + [JsonPropertyName("deletes")] + public List Deletes { get; set; } = new(); } /// @@ -98,11 +163,12 @@ public class SyncResponse /// /// 服务端时间(UTC)。 /// + [JsonPropertyName("serverTimeUtc")] public DateTime ServerTimeUtc { get; set; } /// - /// 当前用户的任务全量。 + /// 当前用户的任务全量(含 Tombstone)。 /// + [JsonPropertyName("tasks")] public List Tasks { get; set; } = new(); -} - +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs index 763d0d4..d09608e 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Hua.Todo.Application.CloudSync.Models; using Hua.Todo.Application.Data; using Hua.Todo.Core.Entities; @@ -7,6 +8,7 @@ namespace Hua.Todo.Application.CloudSync.Services; /// /// 云同步任务服务(按用户隔离)。 +/// 实现 ABP 风格审计字段和 LWW 冲突解决策略。 /// public class CloudTaskSyncService { @@ -22,17 +24,19 @@ public class CloudTaskSyncService } /// - /// 获取指定用户的任务全量。 + /// 获取指定用户的任务全量(含 Tombstone)。 /// /// 用户 ID。 /// 取消令牌。 - /// 任务列表。 + /// 任务列表(含已删除任务)。 public async Task> GetTasksAsync(Guid userId, CancellationToken cancellationToken) { + // 使用 IgnoreQueryFilters 获取包括已删除任务的全量 var tasks = await _dbContext.Tasks .AsNoTracking() + .IgnoreQueryFilters() .Where(t => t.UserId == userId) - .OrderByDescending(t => t.CreatedAt) + .OrderByDescending(t => t.CreationTime) .ToListAsync(cancellationToken); return tasks.Select(MapToItem).ToList(); @@ -40,6 +44,9 @@ public class CloudTaskSyncService /// /// 执行同步(增改删),并返回最新全量。 + /// - 对 deletes 递归标记为已删除(Tombstone) + /// - 对 upserts 按 lastModificationTime 降序处理,较新的先处理 + /// - LWW 冲突解决:客户端 lastModificationTime >= 服务端时接受客户端版本 /// /// 用户 ID。 /// 同步请求。 @@ -49,132 +56,109 @@ public class CloudTaskSyncService { request ??= new SyncRequest(); + // 1. 处理删除(逻辑删除,Tombstone) 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) + foreach (var id in deleteIds) { - _dbContext.Tasks.RemoveRange(toDelete); + await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken); } + + // 保存删除操作 + await _dbContext.SaveChangesAsync(cancellationToken); } + // 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理) if (request.Upserts.Count > 0) { - // 去重:同一 Id 保留最后一条(后发送覆盖先发送) - var uniqueUpserts = request.Upserts - .GroupBy(u => u.Id) - .Select(g => g.Last()) + // 去重策略: + // - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送) + // - 全新任务(Id == null):全部保留,不参与去重 + var dedupedById = request.Upserts + .Where(u => u.Id.HasValue) + .GroupBy(u => u.Id!.Value) + .Select(g => g.Last()); + + var newTasks = request.Upserts + .Where(u => !u.Id.HasValue); + + var uniqueUpserts = dedupedById + .Concat(newTasks) .Where(u => !string.IsNullOrWhiteSpace(u.Title)) + .OrderByDescending(u => u.LastModificationTime) // 较新的先处理 .ToList(); - // 预先查询该用户所有已存在的任务 ID - var existingIds = await _dbContext.Tasks - .Where(t => t.UserId == userId) - .Select(t => t.Id) - .ToHashSetAsync(cancellationToken); + // 使用事务包裹,确保读取与写入的一致性 + // 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突 + // ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景; + // 对于极端并发写入冲突,SaveChangesAsync 处有重试保护 + const int maxSaveRetries = 3; - // 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射) - var clientIdToEntity = new Dictionary(); - - // 第一遍:处理根任务(ParentTaskId == null) - foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue)) + for (int retry = 0; ; retry++) { - TaskEntity? entity = null; + using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); - if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value)) + try { - // 更新已存在任务 - entity = await _dbContext.Tasks - .FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken); - if (entity != null) + // 在事务内查询已有任务,确保读取到最新已提交数据 + var existingTasks = await _dbContext.Tasks + .IgnoreQueryFilters() + .Where(t => t.UserId == userId) + .ToDictionaryAsync(t => t.Id, cancellationToken); + + // 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射) + var clientIdToEntity = new Dictionary(); + + // 第一遍:处理根任务(ParentTaskId == null) + foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue)) { - entity.Title = upsert.Title.Trim(); - entity.Priority = upsert.Priority; - entity.IsCompleted = upsert.IsCompleted; - entity.ParentTaskId = null; - entity.UpdatedAt = DateTime.UtcNow; + await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken); } + + // 保存根任务,获取服务器分配的 ID + await _dbContext.SaveChangesAsync(cancellationToken); + + // 将新创建的根任务加入 existingTasks,供子任务反查 + foreach (var kvp in clientIdToEntity) + { + existingTasks[kvp.Value.Id] = kvp.Value; + } + + // 第二遍:处理子任务(ParentTaskId != null) + foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue)) + { + await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + break; // 成功,退出重试循环 } - - if (entity == null) + catch (DbUpdateException ex) when (retry < maxSaveRetries - 1 + && ex.InnerException is Microsoft.Data.Sqlite.SqliteException se + && se.SqliteErrorCode == 19 + && se.Message.Contains("UNIQUE constraint")) { - // 新建任务;EF Core 会自动分配新的服务器 ID - entity = new TaskEntity - { - UserId = userId, - Title = upsert.Title.Trim(), - Priority = upsert.Priority, - IsCompleted = upsert.IsCompleted, - ParentTaskId = null, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - _dbContext.Tasks.Add(entity); + // 并发写入冲突:其他事务已插入相同 Id 的任务 + // 回滚当前事务,下次循环将重新查询并正确识别已存在实体 + await transaction.RollbackAsync(cancellationToken); - if (upsert.Id.HasValue) + // 清理 ChangeTracker,避免下次循环携带已回滚的实体状态 + foreach (var entry in _dbContext.ChangeTracker.Entries().ToList()) { - clientIdToEntity[upsert.Id.Value] = entity; + entry.State = EntityState.Detached; } + + continue; // 重试 + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; } } - - // 保存根任务,获取服务器分配的 ID - await _dbContext.SaveChangesAsync(cancellationToken); - - // 第二遍:处理子任务(ParentTaskId != null) - foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue)) - { - // 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID - int? parentId = upsert.ParentTaskId; - if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity)) - { - parentId = parentEntity.Id; - } - - TaskEntity? entity = null; - - if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value)) - { - // 更新已存在任务 - entity = await _dbContext.Tasks - .FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken); - if (entity != null) - { - entity.Title = upsert.Title.Trim(); - entity.Priority = upsert.Priority; - entity.IsCompleted = upsert.IsCompleted; - entity.ParentTaskId = parentId; - entity.UpdatedAt = DateTime.UtcNow; - } - } - - if (entity == null) - { - // 新建子任务 - entity = new TaskEntity - { - UserId = userId, - Title = upsert.Title.Trim(), - Priority = upsert.Priority, - IsCompleted = upsert.IsCompleted, - ParentTaskId = parentId, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - _dbContext.Tasks.Add(entity); - - if (upsert.Id.HasValue) - { - clientIdToEntity[upsert.Id.Value] = entity; - } - } - } - - await _dbContext.SaveChangesAsync(cancellationToken); } return new SyncResponse @@ -184,6 +168,163 @@ public class CloudTaskSyncService }; } + /// + /// 递归软删除任务及其子任务。 + /// + private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken) + { + var task = await _dbContext.Tasks + .IgnoreQueryFilters() + .FirstOrDefaultAsync(t => t.UserId == userId && t.Id == taskId, cancellationToken); + + if (task != null && !task.IsDeleted) + { + task.IsDeleted = true; + task.DeletionTime = DateTime.UtcNow; + task.DeleterId = userId; + + // 显式标记为已修改 + _dbContext.Entry(task).State = EntityState.Modified; + + // 递归删除子任务 + var childTasks = await _dbContext.Tasks + .IgnoreQueryFilters() + .Where(t => t.ParentTaskId == taskId) + .ToListAsync(cancellationToken); + + foreach (var child in childTasks) + { + await SoftDeleteTaskRecursiveAsync(userId, child.Id, cancellationToken); + } + } + } + + /// + /// 处理单个 upsert 请求(创建或更新)。 + /// + private async Task ProcessUpsertAsync( + Guid userId, + CloudTaskUpsert upsert, + Dictionary existingTasks, + Dictionary clientIdToEntity, + CancellationToken cancellationToken) + { + // 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID + Guid? parentId = upsert.ParentTaskId; + if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity)) + { + parentId = parentEntity.Id; + } + + TaskEntity? entity = null; + bool shouldAdd = false; + + // 尝试从已缓存的字典中查找 + TaskEntity? existingEntity = null; + bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity); + + if (!foundInCache && upsert.Id.HasValue) + { + // 字典未命中,但客户端提供了 Id —— 可能是跨请求/跨 UserId 的遗留数据 + // 直接查询 DB 以确保不会重复插入 + existingEntity = await _dbContext.Tasks + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == upsert.Id.Value, cancellationToken); + } + + if (existingEntity != null) + { + // 实体已存在(字典或 DB 中),进行 LWW 冲突判断 + if (upsert.LastModificationTime.HasValue && + (!existingEntity.LastModificationTime.HasValue || + upsert.LastModificationTime >= existingEntity.LastModificationTime)) + { + // 客户端版本更新,接受更新 + entity = existingEntity; + entity.Title = (upsert.Title ?? string.Empty).Trim(); + entity.Priority = upsert.Priority; + entity.IsCompleted = upsert.IsCompleted; + entity.Code = upsert.Code ?? string.Empty; + entity.ParentTaskId = parentId; + entity.LastModificationTime = upsert.LastModificationTime; + entity.LastModifierId = userId; + + // 若实体来自 AsNoTracking 查询(非 tracked),需显式附加并标记为 Modified + var entry = _dbContext.Entry(entity); + if (entry.State == EntityState.Detached) + { + _dbContext.Tasks.Attach(entity); + entry.State = EntityState.Modified; + } + else + { + entry.State = EntityState.Modified; + } + } + // 否则保持服务端版本不变,不做任何操作 + } + else + { + // 确认不存在,新建任务 + entity = new TaskEntity + { + Id = upsert.Id ?? Guid.NewGuid(), + UserId = userId, + Title = (upsert.Title ?? string.Empty).Trim(), + Priority = upsert.Priority, + IsCompleted = upsert.IsCompleted, + Code = upsert.Code ?? string.Empty, + ParentTaskId = parentId, + CreationTime = DateTime.UtcNow, + CreatorId = userId, + LastModificationTime = upsert.LastModificationTime ?? DateTime.UtcNow, + LastModifierId = userId, + }; + shouldAdd = true; + } + + if (shouldAdd && entity != null) + { + // 防御性检查:确保实体 Id 未被 ChangeTracker 跟踪,防止 UNIQUE 约束冲突 + var trackedEntry = _dbContext.ChangeTracker.Entries() + .FirstOrDefault(e => e.Entity.Id == entity.Id); + + if (trackedEntry != null) + { + // 实体已被跟踪(可能来自并发操作或其他路径),使用已跟踪的实体 + entity = trackedEntry.Entity; + if (trackedEntry.State != EntityState.Added) + { + // 仅更新非新增实体的字段 + entity.Title = (upsert.Title ?? string.Empty).Trim(); + entity.Priority = upsert.Priority; + entity.IsCompleted = upsert.IsCompleted; + entity.Code = upsert.Code ?? string.Empty; + entity.ParentTaskId = parentId; + entity.LastModificationTime = upsert.LastModificationTime ?? DateTime.UtcNow; + entity.LastModifierId = userId; + trackedEntry.State = EntityState.Modified; + } + } + else + { + _dbContext.Tasks.Add(entity); + } + + // 立即更新 existingTasks,避免同一批次内重复插入相同 Id + existingTasks[entity.Id] = entity; + + if (upsert.Id.HasValue) + { + clientIdToEntity[upsert.Id.Value] = entity; + } + } + } + + /// + /// 将实体映射到 DTO。 + /// private static CloudTaskItem MapToItem(TaskEntity task) { return new CloudTaskItem @@ -192,10 +333,15 @@ public class CloudTaskSyncService Title = task.Title, Priority = task.Priority, IsCompleted = task.IsCompleted, - CreatedAtUtc = task.CreatedAt, - UpdatedAtUtc = task.UpdatedAt, - ParentTaskId = task.ParentTaskId + Code = task.Code, + ParentTaskId = task.ParentTaskId, + CreationTime = task.CreationTime, + CreatorId = task.CreatorId, + LastModificationTime = task.LastModificationTime, + LastModifierId = task.LastModifierId, + IsDeleted = task.IsDeleted, + DeletionTime = task.DeletionTime, + DeleterId = task.DeleterId }; } -} - +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Data/TodoDbContext.cs b/src/Hua.Todo.Application/Data/TodoDbContext.cs index 270d8fb..6acd28a 100644 --- a/src/Hua.Todo.Application/Data/TodoDbContext.cs +++ b/src/Hua.Todo.Application/Data/TodoDbContext.cs @@ -18,7 +18,7 @@ public class TodoDbContext : DbContext } /// - /// 任务集合。 + /// 任务集合(ABP 表名规范:T_Tasks)。 /// public DbSet Tasks { get; set; } @@ -52,17 +52,34 @@ public class TodoDbContext : DbContext var utcDateTimeConverter = new LenientUtcDateTimeStringConverter(); + // TaskEntity 配置(ABP 风格审计字段) modelBuilder.Entity(entity => { - entity.ToTable("Tasks"); + entity.ToTable("T_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).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')"); - entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')"); + entity.Property(e => e.ParentTaskId).HasColumnType("TEXT"); + // ABP 审计字段(基类 FullAuditedEntityWithUser 提供) + entity.Property(e => e.ExtraProperties).HasColumnType("TEXT"); + entity.Property(e => e.ConcurrencyStamp).HasColumnType("TEXT"); + entity.Property(e => e.CreationTime).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')"); + entity.Property(e => e.CreatorId).HasColumnType("TEXT"); + entity.Property(e => e.LastModificationTime).HasConversion(utcDateTimeConverter); + entity.Property(e => e.LastModifierId).HasColumnType("TEXT"); + entity.Property(e => e.IsDeleted).HasDefaultValue(false); + entity.Property(e => e.DeletionTime).HasConversion(utcDateTimeConverter); + entity.Property(e => e.DeleterId).HasColumnType("TEXT"); + + // 软删除查询过滤器 + entity.HasQueryFilter(e => !e.IsDeleted); + + // 导航属性 entity.HasOne(e => e.User) .WithMany(u => u.Tasks) .HasForeignKey(e => e.UserId) @@ -127,4 +144,4 @@ public class TodoDbContext : DbContext entity.HasIndex(e => e.TimestampUtc); }); } -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Interfaces/ITaskService.cs b/src/Hua.Todo.Application/Interfaces/ITaskService.cs index d5df029..0e2beee 100644 --- a/src/Hua.Todo.Application/Interfaces/ITaskService.cs +++ b/src/Hua.Todo.Application/Interfaces/ITaskService.cs @@ -3,66 +3,66 @@ using Hua.Todo.Application.Models; namespace Hua.Todo.Application.Interfaces; /// -/// 任务管理服务接口 +/// 任务管理服务接口。 /// public interface ITaskService : IDynamicApiService { /// - /// 获取所有任务 + /// 获取所有任务。 /// - /// 包含所有任务的列表 + /// 包含所有任务的列表。 Task> GetAllTasksAsync(); /// - /// 根据 ID 获取任务 + /// 根据 ID 获取任务。 /// - /// 任务唯一标识符 - /// 匹配的任务 DTO,如果未找到则返回 null - Task GetTaskByIdAsync(int id); + /// 任务 ID。 + /// 匹配的任务 DTO,如果未找到则返回 null。 + Task GetTaskByIdAsync(Guid id); /// - /// 获取未完成的任务 + /// 获取未完成的任务。 /// - /// 未完成任务的列表 + /// 未完成任务的列表。 Task> GetActiveTasksAsync(); /// - /// 获取已完成的任务 + /// 获取已完成的任务。 /// - /// 已完成任务的列表 + /// 已完成任务的列表。 Task> GetCompletedTasksAsync(); /// - /// 创建新任务 + /// 创建新任务。 /// - /// 创建任务所需的数据传输对象 - /// 创建成功的任务 DTO + /// 创建任务所需的数据传输对象。 + /// 创建成功的任务 DTO。 Task CreateTaskAsync(CreateTaskDto dto); /// - /// 更新任务信息 + /// 更新任务信息。 /// - /// 包含更新信息的任务数据传输对象 - /// 更新后的任务 DTO + /// 包含更新信息的任务数据传输对象。 + /// 更新后的任务 DTO。 Task UpdateTaskAsync(UpdateTaskDto dto); /// - /// 切换任务完成状态 + /// 切换任务完成状态。 /// - /// 任务唯一标识符 - /// 更新状态后的任务 DTO - Task ToggleCompleteAsync(int id); + /// 任务 ID。 + /// 更新状态后的任务 DTO。 + Task ToggleCompleteAsync(Guid id); /// - /// 删除任务 + /// 删除任务。 /// - /// 要删除的任务唯一标识符 - Task DeleteTaskAsync(int id); + /// 要删除的任务 ID。 + Task DeleteTaskAsync(Guid id); /// - /// 获取子任务列表 + /// 获取子任务列表。 /// - /// 父任务唯一标识符 - /// 该父任务下的所有子任务列表 - Task> GetSubTasksAsync(int parentTaskId); -} + /// 父任务 ID。 + /// 该父任务下的所有子任务列表。 + Task> GetSubTasksAsync(Guid parentTaskId); +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs b/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs new file mode 100644 index 0000000..30d149a --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.Designer.cs @@ -0,0 +1,299 @@ +// +using System; +using Hua.Todo.Application.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("MakeTaskEntityAbpCompatible")] + partial class MakeTaskEntityAbpCompatible + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsSuccess") + .HasColumnType("INTEGER"); + + b.Property("TimestampUtc") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TimestampUtc"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowPersist") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsTrustedDeviceOnly") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SecondFactorExpiryMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + 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("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("CreationTime") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime('now')"); + + b.Property("CreatorId") + .HasColumnType("TEXT"); + + b.Property("DeletionTime") + .HasColumnType("TEXT"); + + b.Property("DeleterId") + .HasColumnType("TEXT"); + + b.Property("ExtraProperties") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastModificationTime") + .HasColumnType("TEXT"); + + b.Property("LastModifierId") + .HasColumnType("TEXT"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + 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("T_Tasks", (string)null); + }); + + modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .IsRequired() + .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 + } + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs b/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs new file mode 100644 index 0000000..c98f946 --- /dev/null +++ b/src/Hua.Todo.Application/Migrations/MakeTaskEntityAbpCompatible.cs @@ -0,0 +1,122 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hua.Todo.Application.Migrations +{ + /// + /// + /// 重构 TaskEntity 继承 ABP 风格审计基类。 + /// - 主键从 int 改为 Guid + /// - ParentTaskId 从 int? 改为 Guid? + /// - 新增 ABP 审计字段:ExtraProperties, ConcurrencyStamp, CreationTime, CreatorId, + /// LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId + /// - 重命名 CreatedAt -> CreationTime, UpdatedAt -> LastModificationTime + /// - 表名从 Tasks 改为 T_Tasks + /// + public partial class MakeTaskEntityAbpCompatible : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. 创建临时表 T_Tasks(新结构) + migrationBuilder.CreateTable( + name: "T_Tasks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ExtraProperties = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + CreationTime = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), + CreatorId = table.Column(type: "TEXT", nullable: true), + LastModificationTime = table.Column(type: "TEXT", nullable: true), + LastModifierId = table.Column(type: "TEXT", nullable: true), + IsDeleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + DeletionTime = table.Column(type: "TEXT", nullable: true), + DeleterId = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false, defaultValue: "local"), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), + IsCompleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + Code = table.Column(type: "TEXT", nullable: false, defaultValue: ""), + ParentTaskId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_T_Tasks", x => x.Id); + table.ForeignKey( + name: "FK_T_Tasks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_T_Tasks_T_Tasks_ParentTaskId", + column: x => x.ParentTaskId, + principalTable: "T_Tasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + // 2. 创建索引 + migrationBuilder.CreateIndex( + name: "IX_T_Tasks_ParentTaskId", + table: "T_Tasks", + column: "ParentTaskId"); + + migrationBuilder.CreateIndex( + name: "IX_T_Tasks_UserId", + table: "T_Tasks", + column: "UserId"); + + // 3. 数据迁移(int Id -> Guid,CreatedAt -> CreationTime,UpdatedAt -> LastModificationTime) + // 注意:原有数据会丢失,因为 int 无法直接转换为 Guid + // 如需保留数据,需先导出再导入 + + // 4. 先禁用外键检查,再重命名旧表(避免外键约束失败) + migrationBuilder.Sql("PRAGMA foreign_keys = OFF;"); + migrationBuilder.RenameTable(name: "Tasks", newName: "Tasks_Old"); + migrationBuilder.Sql("PRAGMA foreign_keys = ON;"); + + // 注意:不重命名 T_Tasks 为 Tasks,因为 TodoDbContext 配置的表名是 T_Tasks + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // 此回滚为破坏性操作,数据会丢失 + // 重建旧结构 Tasks 表(仅用于回滚,实际数据不可恢复) + + // 1. 禁用外键检查 + migrationBuilder.Sql("PRAGMA foreign_keys = OFF;"); + + // 2. 删除新表 + migrationBuilder.DropTable(name: "T_Tasks"); + + // 3. 重建旧结构 Tasks 表 + migrationBuilder.CreateTable( + name: "Tasks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false, defaultValue: "local"), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), + IsCompleted = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), + UpdatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"), + ParentTaskId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tasks", x => x.Id); + }); + + // 4. 如果有重命名的旧表,也删除 + migrationBuilder.Sql("DROP TABLE IF EXISTS Tasks_Old;"); + + // 5. 重新启用外键检查 + migrationBuilder.Sql("PRAGMA foreign_keys = ON;"); + } + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs index 3e9f3f0..81cc050 100644 --- a/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs +++ b/src/Hua.Todo.Application/Migrations/TodoDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Hua.Todo.Application.Data; using Microsoft.EntityFrameworkCore; @@ -102,23 +102,52 @@ namespace Hua.Todo.Application.Migrations modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("TEXT"); - b.Property("CreatedAt") - .IsRequired() + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("CreationTime") .ValueGeneratedOnAdd() .HasColumnType("TEXT") .HasDefaultValueSql("datetime('now')"); + b.Property("CreatorId") + .HasColumnType("TEXT"); + + b.Property("DeleterId") + .HasColumnType("TEXT"); + + b.Property("DeletionTime") + .HasColumnType("TEXT"); + + b.Property("ExtraProperties") + .HasColumnType("TEXT"); + b.Property("IsCompleted") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(false); - b.Property("ParentTaskId") - .HasColumnType("INTEGER"); + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastModificationTime") + .HasColumnType("TEXT"); + + b.Property("LastModifierId") + .HasColumnType("TEXT"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT"); b.Property("Priority") .ValueGeneratedOnAdd() @@ -130,12 +159,6 @@ namespace Hua.Todo.Application.Migrations .HasMaxLength(200) .HasColumnType("TEXT"); - b.Property("UpdatedAt") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("datetime('now')"); - b.Property("UserId") .ValueGeneratedOnAdd() .HasColumnType("TEXT") @@ -147,7 +170,7 @@ namespace Hua.Todo.Application.Migrations b.HasIndex("UserId"); - b.ToTable("Tasks", (string)null); + b.ToTable("T_Tasks", (string)null); }); modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => diff --git a/src/Hua.Todo.Application/Models/TaskModels.cs b/src/Hua.Todo.Application/Models/TaskModels.cs index 0493a0b..39ada0e 100644 --- a/src/Hua.Todo.Application/Models/TaskModels.cs +++ b/src/Hua.Todo.Application/Models/TaskModels.cs @@ -13,16 +13,16 @@ public class CreateTaskDto /// public string Title { get; set; } = string.Empty; - [JsonConverter(typeof(JsonStringEnumConverter))] /// /// 任务优先级。 /// + [JsonConverter(typeof(JsonStringEnumConverter))] public TaskPriority Priority { get; set; } = TaskPriority.Medium; /// /// 父任务 ID(用于创建子任务)。 /// - public int? ParentTaskId { get; set; } + public Guid? ParentTaskId { get; set; } } /// @@ -33,17 +33,17 @@ public class UpdateTaskDto /// /// 任务 ID。 /// - public int Id { get; set; } + public Guid Id { get; set; } /// /// 新标题(可选)。 /// public string? Title { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] /// /// 新优先级(可选)。 /// + [JsonConverter(typeof(JsonStringEnumConverter))] public TaskPriority? Priority { get; set; } } @@ -55,34 +55,44 @@ public class TaskDto /// /// 任务 ID。 /// - public int Id { get; set; } + public Guid Id { get; set; } + /// /// 任务标题。 /// public string Title { get; set; } = string.Empty; - [JsonConverter(typeof(JsonStringEnumConverter))] /// /// 任务优先级。 /// + [JsonConverter(typeof(JsonStringEnumConverter))] public TaskPriority Priority { get; set; } /// /// 是否已完成。 /// public bool IsCompleted { get; set; } + + /// + /// 任务编号(用户级自增字符串)。 + /// + public string Code { get; set; } = string.Empty; + /// /// 创建时间(UTC)。 /// - public DateTime CreatedAt { get; set; } + public DateTime CreationTime { get; set; } + /// - /// 更新时间(UTC)。 + /// 最后修改时间(UTC)。 /// - public DateTime UpdatedAt { get; set; } + public DateTime? LastModificationTime { get; set; } + /// /// 父任务 ID(可选)。 /// - public int? ParentTaskId { get; set; } + public Guid? ParentTaskId { get; set; } + /// /// 子任务列表。 /// @@ -99,16 +109,19 @@ public class ApiResponse /// 是否成功。 /// public bool Success { get; set; } + /// /// 返回数据(可选)。 /// public T? Data { get; set; } + /// /// 提示信息。 /// public string Message { get; set; } = string.Empty; + /// /// 错误列表。 /// public List Errors { get; set; } = new(); -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Repositories/TaskRepository.cs b/src/Hua.Todo.Application/Repositories/TaskRepository.cs index 566d540..b50641b 100644 --- a/src/Hua.Todo.Application/Repositories/TaskRepository.cs +++ b/src/Hua.Todo.Application/Repositories/TaskRepository.cs @@ -22,14 +22,17 @@ public class TaskRepository : ITaskRepository } /// - /// 获取所有任务。 + /// 获取所有任务(本地用户),平面加载以支持任意深度树构建。 + /// 不再使用 Include(t => t.SubTasks) 因为 EF Core 无法递归加载未知深度的子任务; + /// 树的构建交由 通过 parentTaskId 在内存中完成。 /// - /// 包含所有任务实体的列表。 + /// 包含所有任务实体的平面列表。 public async Task> GetAllAsync() { return await _context.Tasks + .AsNoTracking() .Where(t => t.UserId == TodoUserIds.LocalUserId) - .Include(t => t.SubTasks) + .OrderByDescending(t => t.CreationTime) .ToListAsync(); } @@ -38,7 +41,7 @@ public class TaskRepository : ITaskRepository /// /// 任务 ID。 /// 匹配的任务实体;如果不存在则返回 null。 - public async Task GetByIdAsync(int id) + public async Task GetByIdAsync(Guid id) { return await _context.Tasks .Include(t => t.SubTasks) @@ -53,7 +56,7 @@ public class TaskRepository : ITaskRepository { return await _context.Tasks .Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted) - .OrderByDescending(t => t.CreatedAt) + .OrderByDescending(t => t.CreationTime) .ToListAsync(); } @@ -65,7 +68,7 @@ public class TaskRepository : ITaskRepository { return await _context.Tasks .Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted) - .OrderByDescending(t => t.UpdatedAt) + .OrderByDescending(t => t.LastModificationTime) .ToListAsync(); } @@ -77,6 +80,12 @@ public class TaskRepository : ITaskRepository public async Task AddAsync(TaskEntity taskEntity) { taskEntity.UserId = TodoUserIds.LocalUserId; + if (taskEntity.Id == Guid.Empty) + { + taskEntity.Id = Guid.NewGuid(); + } + taskEntity.CreationTime = DateTime.UtcNow; + taskEntity.CreatorId = taskEntity.UserId; _context.Tasks.Add(taskEntity); await _context.SaveChangesAsync(); return taskEntity; @@ -89,18 +98,18 @@ public class TaskRepository : ITaskRepository /// 更新后的任务实体。 public async Task UpdateAsync(TaskEntity taskEntity) { - taskEntity.UpdatedAt = DateTime.UtcNow; + taskEntity.LastModificationTime = DateTime.UtcNow; + taskEntity.LastModifierId = taskEntity.UserId; _context.Tasks.Update(taskEntity); await _context.SaveChangesAsync(); return taskEntity; } /// - /// 根据 ID 删除任务。 + /// 根据 ID 删除任务(物理删除)。 /// /// 要删除的任务 ID。 - /// 表示删除操作的任务。 - public async Task DeleteAsync(int id) + public async Task DeleteAsync(Guid id) { var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId); if (task != null) @@ -115,11 +124,11 @@ public class TaskRepository : ITaskRepository /// /// 父任务 ID。 /// 子任务实体的列表。 - public async Task> GetSubTasksAsync(int parentTaskId) + public async Task> GetSubTasksAsync(Guid parentTaskId) { return await _context.Tasks .Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId) - .OrderByDescending(t => t.CreatedAt) + .OrderByDescending(t => t.CreationTime) .ToListAsync(); } -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Application/Services/TaskService.cs b/src/Hua.Todo.Application/Services/TaskService.cs index 494a9ea..adf3f5e 100644 --- a/src/Hua.Todo.Application/Services/TaskService.cs +++ b/src/Hua.Todo.Application/Services/TaskService.cs @@ -6,46 +6,69 @@ using Hua.Todo.Core.Interfaces; namespace Hua.Todo.Application.Services; /// -/// 任务管理服务实现 +/// 任务管理服务实现。 /// public class TaskService : ITaskService { private readonly ITaskRepository _taskRepository; /// - /// 初始化任务管理服务的新实例 + /// 初始化任务管理服务的新实例。 /// - /// 任务仓储接口 + /// 任务仓储接口。 public TaskService(ITaskRepository taskRepository) { _taskRepository = taskRepository; } /// - /// 获取所有任务 + /// 获取所有任务(包含任意深度的子树)。 + /// 先从 DB 平面加载全部任务,再在内存中通过 parentTaskId 构建树结构。 /// - /// 所有任务的 DTO 列表 + /// 完整的任务树 DTO 列表。 public async Task> GetAllTasksAsync() { - var tasks = await _taskRepository.GetAllAsync(); - return tasks.Select(MapToDto).ToList(); + var allTasks = await _taskRepository.GetAllAsync(); + + // 构建 DTO 字典(平面) + var dtoMap = new Dictionary(); + foreach (var task in allTasks) + { + dtoMap[task.Id] = MapToDto(task); + } + + // 通过 parentTaskId 在内存中构建树结构(支持任意深度) + var roots = new List(); + foreach (var dto in dtoMap.Values) + { + if (dto.ParentTaskId.HasValue && dtoMap.TryGetValue(dto.ParentTaskId.Value, out var parent)) + { + parent.SubTasks.Add(dto); + } + else + { + roots.Add(dto); + } + } + + return roots; } /// - /// 根据 ID 获取任务详情 + /// 根据 ID 获取任务详情。 /// - /// 任务 ID - /// 找到的任务 DTO,如果不存在则返回 null - public async Task GetTaskByIdAsync(int id) + /// 任务 ID。 + /// 找到的任务 DTO,如果不存在则返回 null。 + public async Task GetTaskByIdAsync(Guid id) { var task = await _taskRepository.GetByIdAsync(id); return task != null ? MapToDto(task) : null; } /// - /// 获取所有未完成的任务 + /// 获取所有未完成的任务。 /// - /// 未完成任务的 DTO 列表 + /// 未完成任务的 DTO 列表。 public async Task> GetActiveTasksAsync() { var allTasks = await _taskRepository.GetAllAsync(); @@ -53,9 +76,9 @@ public class TaskService : ITaskService } /// - /// 获取所有已完成的任务 + /// 获取所有已完成的任务。 /// - /// 已完成任务的 DTO 列表 + /// 已完成任务的 DTO 列表。 public async Task> GetCompletedTasksAsync() { var allTasks = await _taskRepository.GetAllAsync(); @@ -63,19 +86,24 @@ public class TaskService : ITaskService } /// - /// 创建新任务 + /// 创建新任务。 /// - /// 任务创建 DTO - /// 新创建的任务 DTO + /// 任务创建 DTO。 + /// 新创建的任务 DTO。 public async Task CreateTaskAsync(CreateTaskDto dto) { + var allExisting = await _taskRepository.GetAllAsync(); + var maxCode = allExisting.Count > 0 + ? allExisting.Max(t => int.TryParse(t.Code, out var c) ? c : 0) + : 0; + var task = new TaskEntity { + Id = Guid.NewGuid(), Title = dto.Title, Priority = dto.Priority, IsCompleted = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, + Code = (maxCode + 1).ToString(), ParentTaskId = dto.ParentTaskId }; @@ -84,11 +112,11 @@ public class TaskService : ITaskService } /// - /// 更新现有任务 + /// 更新现有任务。 /// - /// 包含更新内容的 DTO - /// 更新后的任务 DTO - /// 当任务 ID 不存在时抛出 + /// 包含更新内容的 DTO。 + /// 更新后的任务 DTO。 + /// 当任务 ID 不存在时抛出。 public async Task UpdateTaskAsync(UpdateTaskDto dto) { var task = await _taskRepository.GetByIdAsync(dto.Id); @@ -107,19 +135,17 @@ public class TaskService : ITaskService task.Priority = dto.Priority.Value; } - task.UpdatedAt = DateTime.UtcNow; - var updatedTask = await _taskRepository.UpdateAsync(task); return MapToDto(updatedTask); } /// - /// 切换任务的完成状态 + /// 切换任务的完成状态。 /// - /// 任务 ID - /// 状态切换后的任务 DTO - /// 当任务 ID 不存在时抛出 - public async Task ToggleCompleteAsync(int id) + /// 任务 ID。 + /// 状态切换后的任务 DTO。 + /// 当任务 ID 不存在时抛出。 + public async Task ToggleCompleteAsync(Guid id) { var task = await _taskRepository.GetByIdAsync(id); if (task == null) @@ -128,18 +154,17 @@ public class TaskService : ITaskService } task.IsCompleted = !task.IsCompleted; - task.UpdatedAt = DateTime.UtcNow; var updatedTask = await _taskRepository.UpdateAsync(task); return MapToDto(updatedTask); } /// - /// 删除指定 ID 的任务 + /// 删除指定 ID 的任务。 /// - /// 任务 ID - /// 当任务 ID 不存在时抛出 - public async Task DeleteTaskAsync(int id) + /// 任务 ID。 + /// 当任务 ID 不存在时抛出。 + public async Task DeleteTaskAsync(Guid id) { var task = await _taskRepository.GetByIdAsync(id); if (task == null) @@ -151,20 +176,21 @@ public class TaskService : ITaskService } /// - /// 获取指定父任务的所有子任务 + /// 获取指定父任务的所有子任务。 /// - /// 父任务 ID - /// 子任务的 DTO 列表 - public async Task> GetSubTasksAsync(int parentTaskId) + /// 父任务 ID。 + /// 子任务的 DTO 列表。 + public async Task> GetSubTasksAsync(Guid parentTaskId) { var allTasks = await _taskRepository.GetAllAsync(); return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList(); } /// - /// 实体转 DTO 映射方法 + /// 实体转 DTO 映射方法。 + /// 注意:SubTasks 默认为空列表,由 在内存中通过 parentTaskId 构建树。 /// - private TaskDto MapToDto(TaskEntity task) + private static TaskDto MapToDto(TaskEntity task) { return new TaskDto { @@ -172,10 +198,11 @@ public class TaskService : ITaskService Title = task.Title, Priority = task.Priority, IsCompleted = task.IsCompleted, - CreatedAt = task.CreatedAt, - UpdatedAt = task.UpdatedAt, + Code = task.Code, + CreationTime = task.CreationTime, + LastModificationTime = task.LastModificationTime, ParentTaskId = task.ParentTaskId, - SubTasks = task.SubTasks.Select(MapToDto).ToList() + SubTasks = new List() }; } -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Avalonia/Models/AppSettings.cs b/src/Hua.Todo.Avalonia/Models/AppSettings.cs index 3d531ba..cbfea14 100644 --- a/src/Hua.Todo.Avalonia/Models/AppSettings.cs +++ b/src/Hua.Todo.Avalonia/Models/AppSettings.cs @@ -27,6 +27,13 @@ public class WebServerSettings [JsonPropertyName("ForEndUrl")] public string ForEndUrl { get; set; } = "http://localhost:5174"; + + /// + /// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。 + /// 空字符串表示未配置云同步。 + /// + [JsonPropertyName("CloudSyncUrl")] + public string CloudSyncUrl { get; set; } = ""; } public class HotKeyDefaultSettings diff --git a/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs b/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs index 412577b..bae0d2b 100644 --- a/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs +++ b/src/Hua.Todo.Avalonia/Services/EmbeddedWebServerService.cs @@ -11,6 +11,7 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; using Hua.Todo.Application; +using Hua.Todo.Application.CloudSync; using Hua.Todo.Application.Data; using Hua.Todo.Application.DynamicApi; using Hua.Todo.Avalonia.Models; @@ -68,6 +69,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService // 注册应用逻辑服务 builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); + // 注册云同步反向代理(URL 管理 + 转发用 HttpClient) + builder.Services.AddCloudSyncProxy(); + + // 注册 URL 持久化 + builder.Services.AddSingleton(new CloudSyncProxySettingsPersistence( + Path.Combine(AppContext.BaseDirectory, "appsettings.json"))); + // 配置跨域策略 builder.Services.AddCors(options => { @@ -81,6 +89,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService var app = builder.Build(); + // 从 appsettings 初始化 CloudSyncUrl + var proxySettings = app.Services.GetRequiredService(); + if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl)) + { + proxySettings.SetUrl(_appSettings.WebServer.CloudSyncUrl); + } + InitializeDatabase(app); // 如果配置为使用静态文件(前端托管),则配置静态文件服务 @@ -90,9 +105,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService } app.UseCors("AllowAll"); + app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权) app.UseAuthorization(); app.UseDynamicApi(); app.MapControllers(); + app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings _webApp = app; diff --git a/src/Hua.Todo.Avalonia/appsettings.json b/src/Hua.Todo.Avalonia/appsettings.json index 12d9bdc..f6b077b 100644 --- a/src/Hua.Todo.Avalonia/appsettings.json +++ b/src/Hua.Todo.Avalonia/appsettings.json @@ -4,7 +4,8 @@ "IsUsingStatic": true, "ConnectionString": "", "HostUrl": "http://localhost:5057", - "ForEndUrl": "http://localhost:5057" + "ForEndUrl": "http://localhost:5057", + "CloudSyncUrl": "" }, "HotKey": { "DefaultModifiers": "Alt", diff --git a/src/Hua.Todo.Core/Entities/FullAuditedEntityWithUser.cs b/src/Hua.Todo.Core/Entities/FullAuditedEntityWithUser.cs new file mode 100644 index 0000000..3b05428 --- /dev/null +++ b/src/Hua.Todo.Core/Entities/FullAuditedEntityWithUser.cs @@ -0,0 +1,69 @@ +namespace Hua.Todo.Core.Entities; + +/// +/// 完整审计实体基类(ABP 风格),包含创建、修改、删除审计字段。 +/// +/// 主键类型。 +/// 用户实体类型。 +public abstract class FullAuditedEntityWithUser where TKey : struct +{ + /// + /// 主键。 + /// + public TKey Id { get; set; } = default!; + + /// + /// 扩展属性字典(ABP 框架扩展属性)。 + /// 基类已处理序列化,实体中不直接生成属性。 + /// + public string? ExtraProperties { get; set; } + + /// + /// 并发戳,用于乐观并发控制。 + /// + public string? ConcurrencyStamp { get; set; } + + /// + /// 创建时间。 + /// + public DateTime CreationTime { get; set; } + + /// + /// 创建人 ID。 + /// + public TKey? CreatorId { get; set; } + + /// + /// 最后修改时间。 + /// + public DateTime? LastModificationTime { get; set; } + + /// + /// 最后修改人 ID。 + /// + public TKey? LastModifierId { get; set; } + + /// + /// 软删除标记。 + /// + public bool IsDeleted { get; set; } + + /// + /// 删除时间。 + /// + public DateTime? DeletionTime { get; set; } + + /// + /// 删除人 ID。 + /// + public TKey? DeleterId { get; set; } +} + +/// +/// 用户实体接口(用于导航属性类型约束)。 +/// +public interface IUser where TKey : struct +{ + TKey Id { get; set; } + string UserName { get; set; } +} \ No newline at end of file diff --git a/src/Hua.Todo.Core/Entities/TaskEntity.cs b/src/Hua.Todo.Core/Entities/TaskEntity.cs index 242759d..1e5eeb1 100644 --- a/src/Hua.Todo.Core/Entities/TaskEntity.cs +++ b/src/Hua.Todo.Core/Entities/TaskEntity.cs @@ -1,13 +1,13 @@ namespace Hua.Todo.Core.Entities; /// -/// 任务实体类,表示一个代办 +/// 任务实体类,表示一个 Todo 待办项。 +/// 继承 以获得 ABP 风格的审计字段。 /// -public class TaskEntity +public class TaskEntity : FullAuditedEntityWithUser { /// - /// 任务所属用户 ID。 - /// 本地模式下使用固定的“本地用户”ID,以确保本地任务与云端多用户任务隔离。 + /// 任务所属用户 ID(云端隔离),本地模式为 。 /// public Guid UserId { get; set; } = TodoUserIds.LocalUserId; @@ -17,47 +17,38 @@ public class TaskEntity public UserEntity? User { get; set; } /// - /// 任务唯一标识符 - /// - public int Id { get; set; } - - /// - /// 任务标题 + /// 任务标题。 /// public string Title { get; set; } = string.Empty; - + /// - /// 任务优先级 + /// 任务优先级。 /// public TaskPriority Priority { get; set; } = TaskPriority.Medium; - + /// - /// 任务是否已完成 + /// 任务编号(用户级自增字符串,用于展示)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 任务是否已完成。 /// public bool IsCompleted { get; set; } - + /// - /// 任务创建时间(UTC) + /// 父任务 ID(外键),用于支持子任务功能。 + /// 类型为 ,与主键类型一致。 /// - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - + public Guid? ParentTaskId { get; set; } + /// - /// 任务最后更新时间(UTC) - /// - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - /// - /// 父任务ID,用于支持子任务功能 - /// - public int? ParentTaskId { get; set; } - - /// - /// 父任务导航属性 + /// 父任务导航属性。 /// public TaskEntity? ParentTask { get; set; } - + /// - /// 子任务集合 + /// 子任务集合。 /// public List SubTasks { get; set; } = new(); -} +} \ No newline at end of file diff --git a/src/Hua.Todo.Core/Entities/UserEntity.cs b/src/Hua.Todo.Core/Entities/UserEntity.cs index 21ec770..422e047 100644 --- a/src/Hua.Todo.Core/Entities/UserEntity.cs +++ b/src/Hua.Todo.Core/Entities/UserEntity.cs @@ -3,7 +3,7 @@ namespace Hua.Todo.Core.Entities; /// /// 用户实体,用于云同步场景下的用户隔离与权限控制。 /// -public class UserEntity +public class UserEntity : IUser { /// /// 用户唯一标识符。 diff --git a/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs b/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs index 7dd1257..728a271 100644 --- a/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs +++ b/src/Hua.Todo.Core/Interfaces/ITaskRepository.cs @@ -3,60 +3,59 @@ using Hua.Todo.Core.Entities; namespace Hua.Todo.Core.Interfaces; /// -/// 任务仓储接口,定义任务数据访问操作 +/// 任务仓储接口,定义任务数据访问操作。 /// public interface ITaskRepository { /// - /// 获取所有任务 + /// 获取所有任务。 /// - /// 包含所有任务实体的列表任务 + /// 包含所有任务实体的列表。 System.Threading.Tasks.Task> GetAllAsync(); - + /// - /// 根据ID获取指定任务 + /// 根据 ID 获取任务。 /// - /// 任务唯一标识符 - /// 任务实体对象,如果不存在则返回 null 的任务 - System.Threading.Tasks.Task GetByIdAsync(int id); - + /// 任务 ID。 + /// 匹配的任务实体;如果不存在则返回 null。 + System.Threading.Tasks.Task GetByIdAsync(Guid id); + /// - /// 获取所有未完成的任务 + /// 获取未完成任务列表。 /// - /// 未完成任务实体的列表任务 + /// 未完成的任务实体列表。 System.Threading.Tasks.Task> GetActiveTasksAsync(); - + /// - /// 获取所有已完成的任务 + /// 获取已完成任务列表。 /// - /// 已完成任务实体的列表任务 + /// 已完成的任务实体列表。 System.Threading.Tasks.Task> GetCompletedTasksAsync(); - + /// - /// 添加新任务 + /// 新增一个任务。 /// - /// 要添加的任务实体对象 - /// 添加后的任务实体(包含生成的 ID)的任务 + /// 要添加的任务实体。 + /// 已持久化的任务实体(包含生成的 ID)。 System.Threading.Tasks.Task AddAsync(TaskEntity taskEntity); - + /// - /// 更新任务 + /// 更新现有任务信息。 /// - /// 要更新的任务实体对象 - /// 更新后的任务实体对象的任务 + /// 要更新的任务实体。 + /// 更新后的任务实体。 System.Threading.Tasks.Task UpdateAsync(TaskEntity taskEntity); - + /// - /// 删除指定ID的任务 + /// 根据 ID 删除任务(物理删除)。 /// - /// 要删除的任务唯一标识符 - /// 表示删除操作的任务 - System.Threading.Tasks.Task DeleteAsync(int id); - + /// 要删除的任务 ID。 + System.Threading.Tasks.Task DeleteAsync(Guid id); + /// - /// 获取指定父任务的所有子任务 + /// 获取指定父任务的子任务列表。 /// - /// 父任务唯一标识符 - /// 子任务实体的列表任务 - System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId); -} + /// 父任务 ID。 + /// 子任务实体的列表。 + System.Threading.Tasks.Task> GetSubTasksAsync(Guid parentTaskId); +} \ No newline at end of file diff --git a/src/Hua.Todo.Maui/MauiProgram.cs b/src/Hua.Todo.Maui/MauiProgram.cs index a22d77b..f9c1483 100644 --- a/src/Hua.Todo.Maui/MauiProgram.cs +++ b/src/Hua.Todo.Maui/MauiProgram.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Data.Sqlite; using Hua.Todo.Application; +using Hua.Todo.Application.CloudSync; using Hua.Todo.Application.Data; using Hua.Todo.Maui.Models; using Hua.Todo.Maui.Services; diff --git a/src/Hua.Todo.Maui/Models/AppSettings.cs b/src/Hua.Todo.Maui/Models/AppSettings.cs index e5b8803..00a00c6 100644 --- a/src/Hua.Todo.Maui/Models/AppSettings.cs +++ b/src/Hua.Todo.Maui/Models/AppSettings.cs @@ -29,6 +29,13 @@ public class WebServerSettings [JsonPropertyName("ForEndUrl")] public string ForEndUrl { get; set; } = "http://localhost:5174"; + + /// + /// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。 + /// 空字符串表示未配置云同步。 + /// + [JsonPropertyName("CloudSyncUrl")] + public string CloudSyncUrl { get; set; } = ""; } public class HotKeyDefaultSettings diff --git a/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs b/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs index 2410bcc..34f4004 100644 --- a/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs +++ b/src/Hua.Todo.Maui/Services/EmbeddedWebServerService.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using System.Text.Json; using Hua.Todo.Application; +using Hua.Todo.Application.CloudSync; using Hua.Todo.Application.DynamicApi; using Hua.Todo.Application.DynamicApi.Swagger; using Hua.Todo.Maui.Models; @@ -76,6 +77,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService // 注册应用逻辑服务 builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); + // 注册云同步反向代理(URL 管理 + 转发用 HttpClient) + builder.Services.AddCloudSyncProxy(); + + // 注册 URL 持久化 + builder.Services.AddSingleton(new CloudSyncProxySettingsPersistence( + Path.Combine(AppContext.BaseDirectory, "appsettings.json"))); + // 配置跨域策略 builder.Services.AddCors(options => { @@ -89,6 +97,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService var app = builder.Build(); + // 从 appsettings 初始化 CloudSyncUrl + var proxySettings = app.Services.GetRequiredService(); + if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl)) + { + proxySettings.SetUrl(_appSettings.WebServer.CloudSyncUrl); + } + #if DEBUG app.UseSwagger(); app.UseSwaggerUI(); @@ -101,9 +116,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService } app.UseCors("AllowAll"); + app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权) app.UseAuthorization(); app.UseDynamicApi(); app.MapControllers(); + app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings _webApp = app; diff --git a/src/Hua.Todo.Maui/appsettings.json b/src/Hua.Todo.Maui/appsettings.json index 842ac5f..c788dcc 100644 --- a/src/Hua.Todo.Maui/appsettings.json +++ b/src/Hua.Todo.Maui/appsettings.json @@ -4,7 +4,8 @@ "IsUsingStatic": false, "ConnectionString": "", "HostUrl": "http://localhost:5057", - "ForEndUrl": "http://localhost:5174" + "ForEndUrl": "http://localhost:5174", + "CloudSyncUrl": "" }, "Development": { }, diff --git a/src/Hua.Todo.Tests/CloudSyncDtoTests.cs b/src/Hua.Todo.Tests/CloudSyncDtoTests.cs new file mode 100644 index 0000000..3bf83f9 --- /dev/null +++ b/src/Hua.Todo.Tests/CloudSyncDtoTests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Tests; + +/// +/// CloudSync DTO 单元测试。 +/// +public class CloudSyncDtoTests +{ + /// + /// 测试 CloudTaskItem JSON 序列化使用 camelCase。 + /// + [Fact] + public void CloudTaskItem_Serialization_UsesCamelCase() + { + // Arrange + var item = new CloudTaskItem + { + Id = Guid.Parse("12345678-1234-1234-1234-123456789012"), + Title = "Test", + Priority = TaskPriority.High, + IsCompleted = true, + CreationTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + LastModificationTime = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false + }; + + // Act + var json = JsonSerializer.Serialize(item); + var deserialized = JsonSerializer.Deserialize>(json); + + // Assert + Assert.NotNull(deserialized); + Assert.True(deserialized.ContainsKey("id")); + Assert.True(deserialized.ContainsKey("title")); + Assert.True(deserialized.ContainsKey("priority")); + Assert.True(deserialized.ContainsKey("isCompleted")); + Assert.True(deserialized.ContainsKey("creationTime")); + Assert.True(deserialized.ContainsKey("lastModificationTime")); + Assert.True(deserialized.ContainsKey("isDeleted")); + } + + /// + /// 测试 CloudTaskUpsert JSON 反序列化。 + /// + [Fact] + public void CloudTaskUpsert_Deserialization_FromCamelCase() + { + // Arrange + var json = @"{""id"":""12345678-1234-1234-1234-123456789012"",""title"":""Test Task"",""priority"":2,""isCompleted"":true,""lastModificationTime"":""2024-01-02T00:00:00Z""}"; + + // Act + var upsert = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(upsert); + Assert.Equal(Guid.Parse("12345678-1234-1234-1234-123456789012"), upsert.Id); + Assert.Equal("Test Task", upsert.Title); + Assert.Equal(TaskPriority.High, upsert.Priority); + Assert.True(upsert.IsCompleted); + } + + /// + /// 测试 SyncRequest JSON 反序列化。 + /// + [Fact] + public void SyncRequest_Deserialization() + { + // Arrange + var json = @"{ + ""upserts"": [ + {""id"":""12345678-1234-1234-1234-123456789012"",""title"":""Task 1""}, + {""title"":""Task 2""} + ], + ""deletes"": [""87654321-4321-4321-4321-210987654321""] + }"; + + // Act + var request = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(request); + Assert.Equal(2, request.Upserts.Count); + Assert.Single(request.Deletes); + } + + /// + /// 测试 SyncResponse JSON 序列化。 + /// + [Fact] + public void SyncResponse_Serialization() + { + // Arrange + var response = new SyncResponse + { + ServerTimeUtc = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Tasks = new List + { + new CloudTaskItem + { + Id = Guid.NewGuid(), + Title = "Task 1", + Priority = TaskPriority.Medium + } + } + }; + + // Act + var json = JsonSerializer.Serialize(response); + var deserialized = JsonSerializer.Deserialize>(json); + + // Assert + Assert.NotNull(deserialized); + Assert.True(deserialized.ContainsKey("serverTimeUtc")); + Assert.True(deserialized.ContainsKey("tasks")); + } + + /// + /// 测试 Guid 类型的 JSON 序列化。 + /// + [Fact] + public void Guid_Serialization_IsString() + { + // Arrange + var guid = Guid.NewGuid(); + var item = new CloudTaskItem { Id = guid }; + + // Act + var json = JsonSerializer.Serialize(item); + + // Assert + Assert.Contains(guid.ToString(), json); + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs b/src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs new file mode 100644 index 0000000..3cbfca0 --- /dev/null +++ b/src/Hua.Todo.Tests/CloudTaskSyncServiceSqliteTests.cs @@ -0,0 +1,420 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.CloudSync.Services; +using Hua.Todo.Application.Data; +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Tests; + +/// +/// CloudTaskSyncService SQLite 集成测试(验证 UNIQUE 约束不会在生产环境中触发)。 +/// +public class CloudTaskSyncServiceSqliteTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TodoDbContext _dbContext; + private readonly CloudTaskSyncService _service; + private readonly Guid _testUserId; + + public CloudTaskSyncServiceSqliteTests() + { + _testUserId = Guid.NewGuid(); + + // 使用共享缓存的 SQLite 内存数据库 + _connection = new SqliteConnection("Data Source=CloudTaskSyncTests;Mode=Memory;Cache=Shared"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dbContext = new TodoDbContext(options); + _dbContext.Database.EnsureCreated(); + + // 创建测试用户(满足 UserId 外键约束) + _dbContext.Users.Add(new UserEntity + { + Id = _testUserId, + UserName = "test_user", + PasswordHash = "hash", + PasswordSalt = "salt", + Role = "User" + }); + _dbContext.SaveChanges(); + + _service = new CloudTaskSyncService(_dbContext); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + /// + /// 复现用户报告的 Bug:同一批次中同步父子任务(均有已知 Id,lastModificationTime 为 null)。 + /// 期望:首次同步成功创建两个任务。 + /// + [Fact] + public async Task SyncAsync_ParentChildWithNullTime_FirstSync_Succeeds() + { + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = childId, + Title = "子任务", + Priority = TaskPriority.Medium, + Code = "2", + ParentTaskId = parentId, + LastModificationTime = null + }, + new CloudTaskUpsert + { + Id = parentId, + Title = "父任务", + Priority = TaskPriority.High, + Code = "1", + ParentTaskId = null, + LastModificationTime = null + } + } + }; + + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + + Assert.Equal(2, response.Tasks.Count); + var parent = response.Tasks.First(t => t.Title == "父任务"); + Assert.Null(parent.ParentTaskId); + var child = response.Tasks.First(t => t.Title == "子任务"); + Assert.Equal(parentId, child.ParentTaskId); + } + + /// + /// 复现 Bug 场景:首次同步成功后再用相同数据重新同步(模拟客户端重试)。 + /// lastModificationTime 为 null 时 LWW 应跳过更新,不应抛出 UNIQUE 约束。 + /// + [Fact] + public async Task SyncAsync_ParentChildWithNullTime_Resync_NoUniqueViolation() + { + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = childId, Title = "子任务", Priority = TaskPriority.Medium, + Code = "2", ParentTaskId = parentId, LastModificationTime = null + }, + new CloudTaskUpsert + { + Id = parentId, Title = "父任务", Priority = TaskPriority.High, + Code = "1", ParentTaskId = null, LastModificationTime = null + } + } + }; + + // 首次同步 + var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, firstResponse.Tasks.Count); + + // 重新同步(同一 DbContext,模拟客户端重试) + var resyncResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, resyncResponse.Tasks.Count); + } + + /// + /// 测试多次重试同步不引发 UNIQUE 约束。 + /// + [Fact] + public async Task SyncAsync_ParentChildWithNullTime_MultipleResyncs_NoUniqueViolation() + { + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = childId, Title = "子任务", Priority = TaskPriority.Medium, + Code = "2", ParentTaskId = parentId, LastModificationTime = null + }, + new CloudTaskUpsert + { + Id = parentId, Title = "父任务", Priority = TaskPriority.High, + Code = "1", ParentTaskId = null, LastModificationTime = null + } + } + }; + + for (int i = 0; i < 5; i++) + { + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, response.Tasks.Count); + } + } + + /// + /// 测试已有 DbContext 跟踪实体的场景下同步不会引发 UNIQUE 冲突。 + /// 模拟场景:任务已通过其他 API 创建并仍被跟踪,然后通过 CloudSync 同步同一数据。 + /// + [Fact] + public async Task SyncAsync_TaskExistsThenSynced_NoUniqueViolation() + { + var taskId = Guid.NewGuid(); + var existingTask = new TaskEntity + { + Id = taskId, + UserId = _testUserId, + Title = "已有任务", + Priority = TaskPriority.Medium, + CreationTime = DateTime.UtcNow, + CreatorId = _testUserId, + LastModificationTime = DateTime.UtcNow, + LastModifierId = _testUserId + }; + _dbContext.Tasks.Add(existingTask); + await _dbContext.SaveChangesAsync(); + + var syncRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "已有任务", + LastModificationTime = null + } + } + }; + + var response = await _service.SyncAsync(_testUserId, syncRequest, CancellationToken.None); + Assert.Single(response.Tasks); + Assert.Equal("已有任务", response.Tasks.First().Title); + } + + /// + /// 复现用户 Bug 的核心场景:使用独立 DbContext 模拟跨请求重试。 + /// Context A 创建任务并提交,Context B 再次同步相同数据时不应触发 UNIQUE 约束。 + /// + [Fact] + public async Task SyncAsync_CrossContextResync_NoUniqueViolation() + { + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = childId, Title = "子任务", Priority = TaskPriority.Medium, + Code = "2", ParentTaskId = parentId, LastModificationTime = null + }, + new CloudTaskUpsert + { + Id = parentId, Title = "父任务", Priority = TaskPriority.High, + Code = "1", ParentTaskId = null, LastModificationTime = null + } + } + }; + + // Context A:首次同步 + var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, firstResponse.Tasks.Count); + + // Context B:使用全新 DbContext 重新同步(模拟另一个请求) + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + using var dbContextB = new TodoDbContext(options); + var serviceB = new CloudTaskSyncService(dbContextB); + + var resyncResponse = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, resyncResponse.Tasks.Count); + } + + /// + /// 使用报错场景中的精确 UUID 复现 Bug: + /// 父子任务在同一批次中同步,lastModificationTime 均为 null。 + /// + [Fact] + public async Task SyncAsync_ExactBugReportUuids_NoUniqueViolation() + { + // 使用报错日志中的精确 UUID + var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c"); + var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580"); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = childId, + Title = "测试", + Priority = (TaskPriority)1, + IsCompleted = false, + Code = "2", + ParentTaskId = parentId, + LastModificationTime = null + }, + new CloudTaskUpsert + { + Id = parentId, + Title = "测试", + Priority = (TaskPriority)1, + IsCompleted = false, + Code = "1", + ParentTaskId = null, + LastModificationTime = null + } + } + }; + + // 首次同步 + var response1 = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, response1.Tasks.Count); + + // 使用全新 DbContext 重新同步(模拟跨请求重试) + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + using var dbContextB = new TodoDbContext(options); + var serviceB = new CloudTaskSyncService(dbContextB); + + // 重试同步 - 不应抛出 UNIQUE 约束 + var response2 = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, response2.Tasks.Count); + } + + /// + /// 测试同一次同步请求中,父任务已存在于 DB 但 lastModificationTime 为 null 的重同步场景。 + /// + [Fact] + public async Task SyncAsync_ExistingTaskWithNullTime_NoUniqueViolation() + { + // 使用精确 UUID + var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c"); + var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580"); + + // 先创建一个已存在的父任务(模拟前置同步已完成) + var existingParent = new TaskEntity + { + Id = parentId, + UserId = _testUserId, + Title = "测试", + Priority = (TaskPriority)1, + IsCompleted = false, + Code = "1", + ParentTaskId = null, + CreationTime = DateTime.UtcNow, + CreatorId = _testUserId, + LastModificationTime = DateTime.UtcNow, + LastModifierId = _testUserId + }; + _dbContext.Tasks.Add(existingParent); + await _dbContext.SaveChangesAsync(); + + // 同步请求(lastModificationTime 为 null,应被 LWW 跳过) + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = parentId, + Title = "测试", + Priority = (TaskPriority)1, + IsCompleted = false, + Code = "1", + ParentTaskId = null, + LastModificationTime = null // 🔑 关键:null 时间戳 + } + } + }; + + // 不应抛出 UNIQUE 约束 + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Single(response.Tasks); + } + + /// + /// 验证跨 UserId 同步相同 Id 不再触发 UNIQUE 约束。 + /// 任务已存在于 DB(通过其他 UserId 创建),用不同 UserId 同步相同 Id, + /// 应被 ProcessUpsertAsync 的 DB 级别二次查重发现并安全跳过。 + /// + [Fact] + public async Task SyncAsync_TaskExistsWithDifferentUserId_HandlesGracefully() + { + // 创建另一个用户的 DbContext + var otherUserId = Guid.NewGuid(); + _dbContext.Users.Add(new UserEntity + { + Id = otherUserId, + UserName = "other_user", + PasswordHash = "hash", + PasswordSalt = "salt", + Role = "User" + }); + await _dbContext.SaveChangesAsync(); + + // 使用报错 UUID 创建任务,但属于 otherUserId + var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c"); + _dbContext.Tasks.Add(new TaskEntity + { + Id = parentId, + UserId = otherUserId, + Title = "other user task", + Priority = (TaskPriority)1, + CreationTime = DateTime.UtcNow, + CreatorId = otherUserId, + LastModificationTime = DateTime.UtcNow, + LastModifierId = otherUserId + }); + await _dbContext.SaveChangesAsync(); + + // 使用独立 DbContext 同步相同 Id(模拟跨请求) + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + using var dbContextB = new TodoDbContext(options); + var serviceB = new CloudTaskSyncService(dbContextB); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = parentId, + Title = "测试", + Priority = (TaskPriority)1, + IsCompleted = false, + Code = "1", + ParentTaskId = null, + LastModificationTime = null + } + } + }; + + // 应正常完成(不抛出 DbUpdateException),DB 级别二次查重发现已存在实体并跳过 + // 注意:response.Tasks 为空,因为该任务属于 otherUserId,不属于 _testUserId + var response = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Empty(response.Tasks); + } +} diff --git a/src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs b/src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs new file mode 100644 index 0000000..6823b91 --- /dev/null +++ b/src/Hua.Todo.Tests/CloudTaskSyncServiceTests.cs @@ -0,0 +1,678 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Hua.Todo.Application.CloudSync.Models; +using Hua.Todo.Application.CloudSync.Services; +using Hua.Todo.Application.Data; +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Tests; + +/// +/// CloudTaskSyncService 单元测试。 +/// +public class CloudTaskSyncServiceTests : IDisposable +{ + private readonly TodoDbContext _dbContext; + private readonly CloudTaskSyncService _service; + private readonly Guid _testUserId = Guid.NewGuid(); + + public CloudTaskSyncServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + _dbContext = new TodoDbContext(options); + _service = new CloudTaskSyncService(_dbContext); + } + + public void Dispose() + { + _dbContext.Dispose(); + } + + /// + /// 测试创建新任务。 + /// + [Fact] + public async Task SyncAsync_NewTask_CreatesTask() + { + // Arrange + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = Guid.NewGuid(), + Title = "Test Task", + Priority = TaskPriority.High, + IsCompleted = false, + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + + // Assert + Assert.Single(response.Tasks); + var task = response.Tasks.First(); + Assert.Equal("Test Task", task.Title); + Assert.Equal(TaskPriority.High, task.Priority); + Assert.False(task.IsCompleted); + Assert.NotEqual(Guid.Empty, task.Id); + } + + /// + /// 测试更新已有任务。 + /// + [Fact] + public async Task SyncAsync_ExistingTask_UpdatesTask() + { + // Arrange + var taskId = Guid.NewGuid(); + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Updated Task", + Priority = TaskPriority.Low, + IsCompleted = true, + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + await _service.SyncAsync(_testUserId, request, CancellationToken.None); + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Single(response); + var task = response.First(); + Assert.Equal(taskId, task.Id); + Assert.Equal("Updated Task", task.Title); + Assert.Equal(TaskPriority.Low, task.Priority); + Assert.True(task.IsCompleted); + } + + /// + /// 测试删除任务(Tombstone)。 + /// + [Fact] + public async Task SyncAsync_DeleteTask_MarksAsDeleted() + { + // Arrange + var taskId = Guid.NewGuid(); + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Task to Delete", + LastModificationTime = DateTime.UtcNow + } + } + }; + var createResponse = await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + Assert.Single(createResponse.Tasks); + + var deleteRequest = new SyncRequest + { + Deletes = new List { taskId } + }; + + // Act + var deleteResponse = await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None); + + // Assert - 直接检查返回的任务 + var deletedTask = deleteResponse.Tasks.FirstOrDefault(t => t.Id == taskId); + Assert.NotNull(deletedTask); + Assert.True(deletedTask.IsDeleted); + Assert.NotNull(deletedTask.DeletionTime); + } + + /// + /// 测试 LWW 冲突解决:客户端更新时接受较新版本。 + /// + [Fact] + public async Task SyncAsync_LWW_ClientNewer_AcceptsClientVersion() + { + // Arrange + var taskId = Guid.NewGuid(); + var olderTime = DateTime.UtcNow.AddMinutes(-10); + var newerTime = DateTime.UtcNow; + + // 先创建旧版本 + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Original", + LastModificationTime = olderTime + } + } + }; + await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + + // 再提交新版本 + var updateRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Updated by Client", + LastModificationTime = newerTime + } + } + }; + + // Act + await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None); + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Single(response); + var task = response.First(); + Assert.Equal("Updated by Client", task.Title); + } + + /// + /// 测试 LWW 冲突解决:服务端更新时拒绝旧版本。 + /// + [Fact] + public async Task SyncAsync_LWW_ServerNewer_KeepsServerVersion() + { + // Arrange + var taskId = Guid.NewGuid(); + var serverTime = DateTime.UtcNow; + var clientOldTime = DateTime.UtcNow.AddMinutes(-10); + + // 先创建服务端版本 + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Server Version", + LastModificationTime = serverTime + } + } + }; + await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + + // 再提交客户端旧版本 + var updateRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Client Old Version", + LastModificationTime = clientOldTime + } + } + }; + + // Act + await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None); + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Single(response); + var task = response.First(); + Assert.Equal("Server Version", task.Title); + } + + /// + /// 测试获取任务全量(含 Tombstone)。 + /// + [Fact] + public async Task GetTasksAsync_ReturnsAllTasksIncludingDeleted() + { + // Arrange + var taskId = Guid.NewGuid(); + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Task", + LastModificationTime = DateTime.UtcNow + } + } + }; + await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + + var deleteRequest = new SyncRequest + { + Deletes = new List { taskId } + }; + await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None); + + // Act + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Single(response); // GetTasksAsync returns all including deleted + Assert.True(response.First().IsDeleted); + } + + /// + /// 测试父子任务关系。 + /// + [Fact] + public async Task SyncAsync_ParentChildTask_CreatesRelationship() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = parentId, + Title = "Parent Task", + LastModificationTime = DateTime.UtcNow + }, + new CloudTaskUpsert + { + Id = childId, + Title = "Child Task", + ParentTaskId = parentId, + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + await _service.SyncAsync(_testUserId, request, CancellationToken.None); + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Equal(2, response.Count); + var child = response.First(t => t.Title == "Child Task"); + Assert.Equal(parentId, child.ParentTaskId); + } + + /// + /// 测试软删除时递归删除子任务。 + /// + [Fact] + public async Task SyncAsync_DeleteParentTask_RecursiveDeletesChildren() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = parentId, + Title = "Parent", + LastModificationTime = DateTime.UtcNow + }, + new CloudTaskUpsert + { + Id = childId, + Title = "Child", + ParentTaskId = parentId, + LastModificationTime = DateTime.UtcNow + } + } + }; + await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + + var deleteRequest = new SyncRequest + { + Deletes = new List { parentId } + }; + + // Act + await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None); + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Equal(2, response.Count); + Assert.All(response, t => Assert.True(t.IsDeleted)); + } + + /// + /// 测试 Code 字段在创建和返回时正确映射。 + /// + [Fact] + public async Task SyncAsync_Code_IsStoredAndReturned() + { + // Arrange + var taskId = Guid.NewGuid(); + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Task with Code", + Code = "5", + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + + // Assert + Assert.Single(response.Tasks); + Assert.Equal("5", response.Tasks.First().Code); + } + + /// + /// 测试 Code 字段在更新时正确覆盖。 + /// + [Fact] + public async Task SyncAsync_Code_IsUpdated() + { + // Arrange + var taskId = Guid.NewGuid(); + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Original", + Code = "1", + LastModificationTime = DateTime.UtcNow + } + } + }; + await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + + var updateRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Updated", + Code = "99", + LastModificationTime = DateTime.UtcNow.AddMinutes(1) + } + } + }; + + // Act + var response = await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None); + + // Assert + Assert.Single(response.Tasks); + Assert.Equal("99", response.Tasks.First().Code); + } + + /// + /// 测试新任务 Code 为空字符串时的处理。 + /// + [Fact] + public async Task SyncAsync_Code_EmptyString_StoredAsEmpty() + { + // Arrange + var taskId = Guid.NewGuid(); + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "No Code", + Code = string.Empty, + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + + // Assert + Assert.Single(response.Tasks); + Assert.Equal(string.Empty, response.Tasks.First().Code); + } + + /// + /// 测试同一批次中包含子任务引用新创建的父任务(父子任务 Id 均已知)。 + /// 验证第二遍处理子任务时能正确找到已创建父任务的 Id。 + /// + [Fact] + public async Task SyncAsync_ParentChild_SameBatch_ChildFindsNewParent() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = parentId, + Title = "Parent", + LastModificationTime = DateTime.UtcNow + }, + new CloudTaskUpsert + { + Id = childId, + Title = "Child", + ParentTaskId = parentId, + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + await _service.SyncAsync(_testUserId, request, CancellationToken.None); + var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None); + + // Assert + Assert.Equal(2, response.Count); + var child = response.First(t => t.Title == "Child"); + Assert.Equal(parentId, child.ParentTaskId); + var parent = response.First(t => t.Title == "Parent"); + Assert.Null(parent.ParentTaskId); + } + + /// + /// 测试去重:同一批次中相同 Id 出现多次,只保留最后一条。 + /// + [Fact] + public async Task SyncAsync_DuplicateId_LastWins() + { + // Arrange + var taskId = Guid.NewGuid(); + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "First Version", + Code = "1", + LastModificationTime = DateTime.UtcNow.AddMinutes(-5) + }, + new CloudTaskUpsert + { + Id = taskId, + Title = "Last Version", + Code = "2", + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + + // Assert + Assert.Single(response.Tasks); + Assert.Equal("Last Version", response.Tasks.First().Title); + Assert.Equal("2", response.Tasks.First().Code); + } + + /// + /// 测试更新已有任务时不会触发 UNIQUE 约束冲突。 + /// + [Fact] + public async Task SyncAsync_UpdateExistingTask_NoUniqueConstraintViolation() + { + // Arrange - 先创建一个任务 + var taskId = Guid.NewGuid(); + var createRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Original", + Priority = TaskPriority.High, + IsCompleted = false, + LastModificationTime = DateTime.UtcNow.AddMinutes(-5) + } + } + }; + await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None); + + // Act - 用相同的 Id 再次同步(更新),模拟客户端重发 + var updateRequest = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = taskId, + Title = "Updated", + Priority = TaskPriority.Low, + IsCompleted = true, + LastModificationTime = DateTime.UtcNow // 较新时间 + } + } + }; + + // Assert - 不应抛出 DbUpdateException + var response = await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None); + Assert.Single(response.Tasks); + Assert.Equal("Updated", response.Tasks.First().Title); + } + + /// + /// 测试同一批次中父子任务(均有 Id),先处理父再处理子时无 UNIQUE 冲突。 + /// + [Fact] + public async Task SyncAsync_ParentChildSameBatch_NoUniqueConstraintViolation() + { + // Arrange - 父子任务均在同一批次中,模拟客户端新建父子任务后同步 + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = parentId, + Title = "Parent Task", + Priority = TaskPriority.High, + IsCompleted = false, + Code = "1", + ParentTaskId = null, + LastModificationTime = null // 与实际错误场景一致 + }, + new CloudTaskUpsert + { + Id = childId, + Title = "Child Task", + Priority = TaskPriority.Medium, + IsCompleted = false, + Code = "2", + ParentTaskId = parentId, + LastModificationTime = null + } + } + }; + + // Act & Assert - 不应抛出 UNIQUE constraint failed + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + Assert.Equal(2, response.Tasks.Count); + } + + /// + /// 测试 Title 为空或仅空白的 upsert 被过滤掉。 + /// + [Fact] + public async Task SyncAsync_EmptyTitle_FilteredOut() + { + // Arrange + var request = new SyncRequest + { + Upserts = new List + { + new CloudTaskUpsert + { + Id = Guid.NewGuid(), + Title = " ", + LastModificationTime = DateTime.UtcNow + }, + new CloudTaskUpsert + { + Id = Guid.NewGuid(), + Title = "", + LastModificationTime = DateTime.UtcNow + }, + new CloudTaskUpsert + { + Id = Guid.NewGuid(), + Title = "Valid Task", + LastModificationTime = DateTime.UtcNow + } + } + }; + + // Act + var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None); + + // Assert + Assert.Single(response.Tasks); + Assert.Equal("Valid Task", response.Tasks.First().Title); + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Tests/Hua.Todo.Tests.csproj b/src/Hua.Todo.Tests/Hua.Todo.Tests.csproj new file mode 100644 index 0000000..c101ce2 --- /dev/null +++ b/src/Hua.Todo.Tests/Hua.Todo.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + \ No newline at end of file diff --git a/src/Hua.Todo.Tests/TaskEntityTests.cs b/src/Hua.Todo.Tests/TaskEntityTests.cs new file mode 100644 index 0000000..84bbc05 --- /dev/null +++ b/src/Hua.Todo.Tests/TaskEntityTests.cs @@ -0,0 +1,123 @@ +using Hua.Todo.Core.Entities; +using Xunit; + +namespace Hua.Todo.Tests; + +/// +/// TaskEntity 单元测试。 +/// +public class TaskEntityTests +{ + /// + /// 测试 TaskEntity 继承自 FullAuditedEntityWithUser。 + /// + [Fact] + public void TaskEntity_InheritsFromFullAuditedEntityWithUser() + { + // Arrange & Act + var task = new TaskEntity(); + + // Assert + Assert.IsAssignableFrom>(task); + } + + /// + /// 测试 TaskEntity 主键为 Guid 类型。 + /// + [Fact] + public void TaskEntity_IdIsGuid() + { + // Arrange & Act + var task = new TaskEntity(); + + // Assert + Assert.Equal(Guid.Empty, task.Id); + } + + /// + /// 测试 TaskEntity 默认值。 + /// + [Fact] + public void TaskEntity_DefaultValues() + { + // Arrange & Act + var task = new TaskEntity(); + + // Assert + Assert.Equal(string.Empty, task.Title); + Assert.Equal(TaskPriority.Medium, task.Priority); + Assert.False(task.IsCompleted); + Assert.Null(task.ParentTaskId); + Assert.Empty(task.SubTasks); + Assert.False(task.IsDeleted); + Assert.Null(task.DeletionTime); + Assert.Null(task.DeleterId); + } + + /// + /// 测试创建任务时生成 Guid。 + /// + [Fact] + public void TaskEntity_NewInstance_HasUniqueId() + { + // Arrange & Act + var task1 = new TaskEntity { Id = Guid.NewGuid() }; + var task2 = new TaskEntity { Id = Guid.NewGuid() }; + + // Assert + Assert.NotEqual(task1.Id, task2.Id); + } +} + +/// +/// FullAuditedEntityWithUser 基类单元测试。 +/// +public class FullAuditedEntityWithUserTests +{ + /// + /// 测试 ABP 审计字段默认值。 + /// + [Fact] + public void FullAuditedEntityWithUser_DefaultValues() + { + // Arrange & Act + var entity = new TestAuditedEntity(); + + // Assert + Assert.Equal(Guid.Empty, entity.Id); + Assert.False(entity.IsDeleted); + Assert.Null(entity.DeletionTime); + Assert.Null(entity.DeleterId); + Assert.Null(entity.ExtraProperties); + Assert.Null(entity.ConcurrencyStamp); + Assert.Equal(default, entity.CreationTime); + Assert.Null(entity.CreatorId); + Assert.Null(entity.LastModificationTime); + Assert.Null(entity.LastModifierId); + } + + /// + /// 测试 IUser 接口实现。 + /// + [Fact] + public void UserEntity_ImplementsIUser() + { + // Arrange & Act + var user = new UserEntity + { + Id = Guid.NewGuid(), + UserName = "testuser" + }; + + // Assert + Assert.IsAssignableFrom>(user); + Assert.Equal("testuser", user.UserName); + } + + /// + /// 测试子类。 + /// + private class TestAuditedEntity : FullAuditedEntityWithUser + { + } +} \ No newline at end of file diff --git a/src/Hua.Todo.Web/.env.maui b/src/Hua.Todo.Web/.env.maui index c28215b..870194e 100644 --- a/src/Hua.Todo.Web/.env.maui +++ b/src/Hua.Todo.Web/.env.maui @@ -1,2 +1,3 @@ -# MAUI 模式:API 与云同步共用本地后端代理目标 +# MAUI 模式:所有请求代理到 MAUI 嵌入式 WebServer +# 云同步请求由嵌入式服务器的 CloudSyncProxyMiddleware 转发到远程 Host VITE_API_TARGET=http://localhost:5057 \ No newline at end of file diff --git a/src/Hua.Todo.Web/src/api/cloudClient.ts b/src/Hua.Todo.Web/src/api/cloudClient.ts index e3f4229..b334c5f 100644 --- a/src/Hua.Todo.Web/src/api/cloudClient.ts +++ b/src/Hua.Todo.Web/src/api/cloudClient.ts @@ -32,17 +32,10 @@ const cloudClient = axios.create({ }); cloudClient.interceptors.request.use((config) => { - // 根据运行模式决定 baseURL: - // - Host 模式(Vite dev):使用同源请求,通过 Vite proxy 到 Host - // - MAUI 模式:使用配置的 serverUrl 连接外部云同步服务器 - const isMaui = (window as any).__IS_MAUI__ === true; - if (isMaui) { - const { serverUrl } = CloudSyncStorage.loadSettings(); - if (serverUrl) { - config.baseURL = serverUrl; - } - } - // Host 模式下不设置 baseURL,axios 使用浏览器默认同源 + // MAUI 模式:使用同源请求,由嵌入式 WebServer 的云同步代理中间件转发到远程 Host。 + // 不再从 localStorage 读取 serverUrl —— 地址由嵌入式服务器的 CloudSyncProxySettings 管理。 + // Host 模式(Vite dev):也使用同源请求,通过 Vite proxy 转发到 Host。 + // 两种模式均不设置 baseURL,axios 使用浏览器默认同源。 const session = CloudSyncStorage.loadSession(); if (session?.accessToken) { diff --git a/src/Hua.Todo.Web/src/api/cloudSync.ts b/src/Hua.Todo.Web/src/api/cloudSync.ts index a33104e..c80abe8 100644 --- a/src/Hua.Todo.Web/src/api/cloudSync.ts +++ b/src/Hua.Todo.Web/src/api/cloudSync.ts @@ -1,12 +1,18 @@ import cloudClient from './cloudClient'; import CloudSyncStorage from '../services/cloudSyncStorage'; -import type { Task } from '../types/task'; +import type { Task, TaskPriority } from '../types/task'; +/** + * 登录请求。 + */ export interface CloudLoginRequest { userName: string; password: string; } +/** + * 登录响应。 + */ export interface CloudLoginResponse { accessToken: string; expiresAtUtc: string; @@ -15,14 +21,27 @@ export interface CloudLoginResponse { permissions: string[]; } +/** + * 云同步任务条目(与 CloudTaskItem DTO 对应)。 + * - id 为 Guid 序列化后的字符串 + * - 包含 ABP 审计字段 + */ export interface CloudTaskItem { - id: number; + id: string; title: string; - priority: 0 | 1 | 2; + priority: TaskPriority; isCompleted: boolean; - createdAtUtc: string; - updatedAtUtc: string; - parentTaskId?: number | null; + code: string; + parentTaskId: string | null; + + // === ABP 审计字段 === + creationTime: string; + creatorId: string | null; + lastModificationTime: string | null; + lastModifierId: string | null; + isDeleted: boolean; + deletionTime: string | null; + deleterId: string | null; } /** @@ -30,11 +49,13 @@ export interface CloudTaskItem { * 注意:id 为 null/undefined 表示新建任务,由服务端分配 ID。 */ export interface CloudTaskUpsert { - id?: number | null; + id?: string | null; title: string; - priority: 0 | 1 | 2; + priority: TaskPriority; isCompleted: boolean; - parentTaskId?: number | null; + code: string; + parentTaskId?: string | null; + lastModificationTime?: string | null; } /** @@ -42,7 +63,7 @@ export interface CloudTaskUpsert { */ export interface CloudSyncRequest { upserts: CloudTaskUpsert[]; - deletes: number[]; + deletes: string[]; } /** @@ -53,6 +74,9 @@ export interface CloudSyncResponse { tasks: CloudTaskItem[]; } +/** + * 安全策略响应。 + */ export interface SecurityPolicyResponse { allowPersist: boolean; allowSync: boolean; @@ -74,16 +98,22 @@ export interface ProbeServerUrlResponse { * 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。 */ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { - const map = new Map(); + 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, + code: item.code ?? '', + parentTaskId: item.parentTaskId, + creationTime: item.creationTime, + creatorId: item.creatorId, + lastModificationTime: item.lastModificationTime, + lastModifierId: item.lastModifierId, + isDeleted: item.isDeleted, + deletionTime: item.deletionTime, + deleterId: item.deleterId, subTasks: [], }); } @@ -91,7 +121,7 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { const roots: Task[] = []; for (const task of map.values()) { const parentId = task.parentTaskId; - if (typeof parentId === 'number' && map.has(parentId)) { + if (parentId != null && map.has(parentId)) { map.get(parentId)!.subTasks.push(task); } else { roots.push(task); @@ -103,25 +133,29 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { /** * 把前端的任务树结构扁平化为 CloudTaskUpsert 列表,用于发送到云端。 - * - id > 0 表示已同步到云端的任务(保留 id 用于更新) - * - id <= 0 或未设置表示本地新建任务(id 置为 null,由服务端分配新 ID) - * - 使用 seen 集合去重:同一任务(按 id 或 hash)只发送一次 + * - id 存在且有效(Guid 格式)表示已同步到云端的任务(保留 id 用于更新) + * - id 为空或无效表示本地新建任务(id 置为 null/undefined,由服务端分配新 ID) + * - 使用 seen 集合去重:同一任务(按 id)只发送一次 */ export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => { const result: CloudTaskUpsert[] = []; - const seenIds = new Set(); + const seenIds = new Set(); const walk = (items: Task[]) => { for (const task of items) { - const id = task.id > 0 ? task.id : null; + // 有效 Guid 字符串作为已同步任务的标识,否则视为新建任务 + const id = isValidGuid(task.id) ? task.id : null; // 去重:同一 id 只保留第一次出现 if (id != null && seenIds.has(id)) continue; if (id != null) seenIds.add(id); result.push({ id, title: task.title, - priority: task.priority as 0 | 1 | 2, + priority: task.priority as TaskPriority, isCompleted: Boolean(task.isCompleted), - parentTaskId: typeof task.parentTaskId === 'number' && task.parentTaskId > 0 ? task.parentTaskId : null, + code: task.code ?? '', + parentTaskId: isValidGuid(task.parentTaskId ?? '') ? task.parentTaskId : null, + // 确保 lastModificationTime 不为 null,否则服务端 LWW 会拒绝客户端变更 + lastModificationTime: task.lastModificationTime || new Date().toISOString(), }); if (task.subTasks && task.subTasks.length > 0) { walk(task.subTasks); @@ -132,6 +166,13 @@ export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => return result; }; +/** + * 判断字符串是否为有效 Guid 格式。 + */ +function isValidGuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + /** * 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。 */ @@ -180,7 +221,7 @@ export const cloudSyncApi = { * 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。 * 注意:该端点需要已登录 + 二次认证(step-up)。 */ - async syncTasks(localTasks: Task[], deleteIds: number[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> { + async syncTasks(localTasks: Task[], deleteIds: string[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> { const upserts = flattenTasksToCloudUpserts(localTasks); const body: CloudSyncRequest = { upserts, deletes: deleteIds }; const response = await cloudClient.post('/sync/', body); diff --git a/src/Hua.Todo.Web/src/api/tasks.ts b/src/Hua.Todo.Web/src/api/tasks.ts index c4f0fc6..93a44e2 100644 --- a/src/Hua.Todo.Web/src/api/tasks.ts +++ b/src/Hua.Todo.Web/src/api/tasks.ts @@ -2,6 +2,7 @@ import apiClient from './client'; import type { Task, CreateTaskDto, UpdateTaskDto, ApiResponse } from '../types/task'; import LocalStorageService from '../services/localStorageService'; import { normalizeTask, normalizeTasks } from '../services/taskNormalizer'; +import { generateGuid } from '../utils/guid'; export const taskApi = { /** @@ -63,7 +64,7 @@ export const taskApi = { * @param id 任务 ID * @returns 包含任务对象或 null 的响应对象 */ - async getTask(id: number): Promise> { + async getTask(id: string): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const task = localTasks.find(t => t.id === id); @@ -102,19 +103,31 @@ export const taskApi = { * @returns 包含新创建任务的响应对象 */ async createTask(dto: CreateTaskDto): Promise> { + const now = new Date().toISOString(); + // 离线时从本地已有任务生成 code + const localTasks = LocalStorageService.loadTasks(); + const maxCode = localTasks.reduce((max, t) => { + const n = parseInt(t.code ?? '0', 10); + return isNaN(n) ? max : Math.max(max, n); + }, 0); const newTask: Task = { - id: LocalStorageService.getNextTaskId(), + id: generateGuid(), title: dto.title, priority: dto.priority, isCompleted: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - parentTaskId: dto.parentTaskId, + code: String(maxCode + 1), + creationTime: now, + creatorId: null, + lastModificationTime: now, + lastModifierId: null, + isDeleted: false, + deletionTime: null, + deleterId: null, + parentTaskId: dto.parentTaskId ?? null, subTasks: [] }; if (!LocalStorageService.isOnline()) { - const localTasks = LocalStorageService.loadTasks(); localTasks.unshift(newTask); LocalStorageService.saveTasks(localTasks); @@ -167,13 +180,13 @@ export const taskApi = { * @param dto 更新任务的数据传输对象 * @returns 包含更新后任务的响应对象 */ - async updateTask(id: number, dto: UpdateTaskDto): Promise> { + async updateTask(id: string, dto: UpdateTaskDto): Promise> { const updateDto = dto; - + if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const taskIndex = localTasks.findIndex(t => t.id === id); - + if (taskIndex !== -1) { if (dto.title) { localTasks[taskIndex].title = dto.title; @@ -181,7 +194,7 @@ export const taskApi = { if (dto.priority !== undefined) { localTasks[taskIndex].priority = dto.priority; } - localTasks[taskIndex].updatedAt = new Date().toISOString(); + localTasks[taskIndex].lastModificationTime = new Date().toISOString(); LocalStorageService.saveTasks(localTasks); const syncStatus = LocalStorageService.loadSyncStatus(); @@ -234,14 +247,14 @@ export const taskApi = { * @param id 任务 ID * @returns 包含更新后任务的响应对象 */ - async toggleComplete(id: number): Promise> { + async toggleComplete(id: string): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const taskIndex = localTasks.findIndex(t => t.id === id); if (taskIndex !== -1) { localTasks[taskIndex].isCompleted = !localTasks[taskIndex].isCompleted; - localTasks[taskIndex].updatedAt = new Date().toISOString(); + localTasks[taskIndex].lastModificationTime = new Date().toISOString(); LocalStorageService.saveTasks(localTasks); const syncStatus = LocalStorageService.loadSyncStatus(); @@ -294,7 +307,7 @@ export const taskApi = { * @param id 任务 ID * @returns 包含操作结果的响应对象 */ - async deleteTask(id: number): Promise> { + async deleteTask(id: string): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); const taskIndex = localTasks.findIndex(t => t.id === id); diff --git a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue index 20d4103..5a2e224 100644 --- a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue +++ b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue @@ -144,6 +144,7 @@ * 通过 CustomEvent 与其他组件通信(cloudSyncStateChanged、cloudSyncPullTasksRequested)。 */ import { computed, onMounted, ref } from 'vue'; +import axios from 'axios'; import CloudSyncStorage from '../services/cloudSyncStorage'; import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync'; import LocalStorageService, { type SyncStatus } from '../services/localStorageService'; @@ -216,17 +217,30 @@ const canSync = computed(() => { // ========== 核心方法 ========== /** - * 从本地存储加载设置状态 - * 并在已登录时自动获取安全策略和同步状态 + * 从本地 API 加载云同步服务端地址。 + * 启用状态仍从 localStorage 读取(客户端偏好)。 + * 并在已登录时自动获取安全策略和同步状态。 */ -const loadLocalState = () => { +const loadLocalState = async () => { refreshSession(); - const settings = CloudSyncStorage.loadSettings(); - localEnabled.value = settings.enabled; - serverUrlSaved.value = settings.serverUrl; - serverUrlInput.value = settings.serverUrl; - - refreshSyncStatus(); // 加载同步状态 + + // 从嵌入式 WebServer 的本地 API 获取服务端地址 + try { + const res = await axios.get('/api/cloud-sync/settings'); + const serverUrl = (res.data as any)?.serverUrl ?? ''; + serverUrlSaved.value = serverUrl; + serverUrlInput.value = serverUrl; + } catch { + // 嵌入式服务器未就绪或接口不存在,回退到 localStorage + const settings = CloudSyncStorage.loadSettings(); + serverUrlSaved.value = settings.serverUrl; + serverUrlInput.value = settings.serverUrl; + } + + // 启用状态保持 localStorage(客户端偏好) + localEnabled.value = CloudSyncStorage.loadSettings().enabled; + + refreshSyncStatus(); if (isLoggedIn.value) { refreshPolicy(); } @@ -367,7 +381,7 @@ const emitCloudSyncStateChanged = () => { const event = new CustomEvent('cloudSyncStateChanged', { detail: { enabled: settings.enabled, - serverUrl: settings.serverUrl, + serverUrl: serverUrlSaved.value || settings.serverUrl, isLoggedIn: Boolean(session?.accessToken), }, }); @@ -410,10 +424,17 @@ const saveServerUrl = async () => { } serverUrlSaved.value = parsed.normalized; - CloudSyncStorage.saveSettings({ - serverUrl: serverUrlSaved.value, - enabled: localEnabled.value, - }); + + // 写入嵌入式 WebServer 的本地 API(持久化到 appsettings.json) + try { + await axios.post('/api/cloud-sync/settings', { serverUrl: serverUrlSaved.value }); + } catch { + // 回退到 localStorage + CloudSyncStorage.saveSettings({ + serverUrl: serverUrlSaved.value, + enabled: localEnabled.value, + }); + } if (parsed.warn) { showToast(parsed.warn, 'success'); @@ -423,7 +444,7 @@ const saveServerUrl = async () => { isProbing.value = true; try { - // 调用 Host 的探测接口,由服务端发起探测(避免浏览器直连的 CORS/证书/网络限制) + // 探测走同源代理(cloudSyncApi 通过 cloudClient → 同源 → 代理中间件 → 远程 Host) const probeResponse = await cloudSyncApi.probeServerUrl(serverUrlSaved.value); probeResult.value = { type: probeResponse.type as ProbeType, diff --git a/src/Hua.Todo.Web/src/components/TaskItem.vue b/src/Hua.Todo.Web/src/components/TaskItem.vue index 98b1f16..4e3b7bb 100644 --- a/src/Hua.Todo.Web/src/components/TaskItem.vue +++ b/src/Hua.Todo.Web/src/components/TaskItem.vue @@ -18,7 +18,7 @@
{{ task.title }}
- #{{ task.id }} + #{{ task.code }} {{ priorityShortText }} 创建{{ createdAtText }}
@@ -98,7 +98,7 @@ const props = defineProps(); const emit = defineEmits<{ (e: 'updated', task: Task): void; - (e: 'deleted', id: number): void; + (e: 'deleted', id: string): void; (e: 'subtask-created', task: Task): void; }>(); @@ -145,7 +145,7 @@ const formatDateTime = (value: string): string => { return `${y}-${m}-${d} ${hh}:${mm}:${ss}`; }; -const createdAtText = computed(() => formatDateTime(props.task.createdAt)); +const createdAtText = computed(() => formatDateTime(props.task.creationTime)); const toggleExpand = () => { isExpanded.value = !isExpanded.value; @@ -207,7 +207,7 @@ const handleSubTaskUpdated = (updatedTask: Task) => { } }; -const handleSubTaskDeleted = (subTaskId: number) => { +const handleSubTaskDeleted = (subTaskId: string) => { if (props.task.subTasks) { props.task.subTasks = props.task.subTasks.filter(t => t.id !== subTaskId); emit('updated', props.task); diff --git a/src/Hua.Todo.Web/src/components/TaskList.vue b/src/Hua.Todo.Web/src/components/TaskList.vue index 3a7cb57..8f464c2 100644 --- a/src/Hua.Todo.Web/src/components/TaskList.vue +++ b/src/Hua.Todo.Web/src/components/TaskList.vue @@ -286,21 +286,21 @@ const sortTasks = (tasks: Task[]): Task[] => { const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority]; comparison = priorityDiff; if (comparison === 0) { - comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(); } break; case 'completedAt': { - const aVal = a.isCompleted ? new Date(a.updatedAt).getTime() : 0; - const bVal = b.isCompleted ? new Date(b.updatedAt).getTime() : 0; + const aVal = a.isCompleted && a.lastModificationTime ? new Date(a.lastModificationTime).getTime() : 0; + const bVal = b.isCompleted && b.lastModificationTime ? new Date(b.lastModificationTime).getTime() : 0; comparison = bVal - aVal; if (comparison === 0) { - comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(); } break; } case 'createdAt': default: - comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime(); break; } @@ -414,12 +414,12 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => { return false; }; -const handleTaskDeleted = (taskId: number) => { +const handleTaskDeleted = (taskId: string) => { deleteTaskFromTree(tasks.value, taskId); lastSyncTime.value = Date.now(); }; -const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => { +const deleteTaskFromTree = (tasks: Task[], taskId: string): boolean => { for (let i = 0; i < tasks.length; i++) { if (tasks[i].id === taskId) { tasks.splice(i, 1); @@ -486,7 +486,10 @@ const handleCloudSyncStateChanged = () => { /** * 立即同步:将本地任务推送到云端并获取云端最新数据。 - * 通过 cloudSyncApi.syncTasks 调用 POST /sync/:推送 upserts 列表,云端返回最终状态。 + * 策略:服务端为单一真相源,但合并而非盲目替换 —— + * 1. 上传所有本地任务(upserts) + * 2. 服务端按 LWW 合并并返回全量 + * 3. 以服务端列表为准,同时保留服务端未返回的本地任务(防止数据丢失) */ const syncNow = async () => { if (isSyncing.value) return; @@ -495,9 +498,61 @@ const syncNow = async () => { const localTasks = tasks.value.length > 0 ? tasks.value : LocalStorageService.loadTasks(); + + // 上传统计所有本地任务 ID,用于后续合并校验 + const localTaskIds = new Set(); + const collectIds = (items: Task[]) => { + for (const t of items) { + if (t.id) localTaskIds.add(String(t.id)); + if (t.subTasks?.length) collectIds(t.subTasks); + } + }; + collectIds(localTasks); + const { tasks: cloudTasks, serverTimeUtc } = await cloudSyncApi.syncTasks(localTasks); - tasks.value = cloudTasks; - LocalStorageService.saveTasks(cloudTasks); + + // 构建云端任务 ID 索引 + const cloudTaskMap = new Map(); + const collectCloudIds = (items: Task[]) => { + for (const t of items) { + if (t.id) cloudTaskMap.set(String(t.id), t); + if (t.subTasks?.length) collectCloudIds(t.subTasks); + } + }; + collectCloudIds(cloudTasks); + + // 合并策略:云端已有的任务以云端为准;云端没有的本地任务保留(附时间戳确保下次能被接受) + const mergedTasks: Task[] = [...cloudTasks]; + const nowIso = new Date().toISOString(); + + // 递归收集云端缺失的本地任务,正确构建子任务树结构 + const collectMissingLocals = (items: Task[]): Task[] => { + const result: Task[] = []; + for (const t of items) { + const id = String(t.id); + if (cloudTaskMap.has(id)) { + // 任务已在云端返回中 → 跳过(云端版本已在 mergedTasks 中,含正确子树) + continue; + } + // 任务不在云端返回中 → 保留本地版本,递归处理子任务 + const preserved: Task = { + ...t, + lastModificationTime: t.lastModificationTime || nowIso, + }; + if (t.subTasks?.length) { + preserved.subTasks = collectMissingLocals(t.subTasks); + } + result.push(preserved); + console.warn(`[CloudSync] 任务 ${id} ("${t.title}") 未在云端返回列表中找到,已保留本地副本`); + } + return result; + }; + + const missingLocals = collectMissingLocals(localTasks); + mergedTasks.push(...missingLocals); + + tasks.value = mergedTasks; + LocalStorageService.saveTasks(mergedTasks); const syncStatus = LocalStorageService.loadSyncStatus(); const serverTimestamp = serverTimeUtc ? new Date(serverTimeUtc).getTime() : Date.now(); syncStatus.lastSyncTime = serverTimestamp; @@ -506,7 +561,7 @@ const syncNow = async () => { LocalStorageService.saveSyncStatus(syncStatus); lastSyncTime.value = syncStatus.lastSyncTime; pendingChanges.value = 0; - LocalStorageService.syncTaskIdCounter(cloudTasks); + LocalStorageService.syncTaskIdCounter(mergedTasks); showToast('同步成功', 'success'); } catch (error) { console.error('Sync failed:', error); diff --git a/src/Hua.Todo.Web/src/services/taskNormalizer.ts b/src/Hua.Todo.Web/src/services/taskNormalizer.ts index 19524e4..f69e9e2 100644 --- a/src/Hua.Todo.Web/src/services/taskNormalizer.ts +++ b/src/Hua.Todo.Web/src/services/taskNormalizer.ts @@ -26,25 +26,24 @@ const toTaskPriority = (value: unknown): TaskPriority => { return 1; }; -const toIsoString = (value: unknown): string => { - if (typeof value === 'string' && value.trim()) return value; - if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); - if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString(); - return new Date().toISOString(); -}; - export const normalizeTask = (task: any): Task => { const normalized: any = { ...(task ?? {}) }; - if (typeof normalized.id === 'string' && /^\d+$/.test(normalized.id)) { - normalized.id = Number(normalized.id); - } + // id 现在是 Guid 字符串,不再进行类型转换 + // 保留原始 id 即可(可能是 string 或 number,取决于数据来源) normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? ''); normalized.priority = toTaskPriority(normalized.priority); normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted); - normalized.createdAt = toIsoString(normalized.createdAt); - normalized.updatedAt = toIsoString(normalized.updatedAt); + normalized.code = typeof normalized.code === 'string' ? normalized.code : ''; + + // 统一字段名:createdAt/updatedAt(旧) -> creationTime/lastModificationTime(新) + if (!normalized.creationTime) { + normalized.creationTime = normalized.createdAt || normalized.creationTime || new Date().toISOString(); + } + if (!normalized.lastModificationTime) { + normalized.lastModificationTime = normalized.updatedAt || normalized.lastModificationTime || null; + } if (normalized.parentTaskId === null) { normalized.parentTaskId = undefined; diff --git a/src/Hua.Todo.Web/src/types/task.ts b/src/Hua.Todo.Web/src/types/task.ts index 0b5a282..b97ef92 100644 --- a/src/Hua.Todo.Web/src/types/task.ts +++ b/src/Hua.Todo.Web/src/types/task.ts @@ -1,26 +1,65 @@ export type TaskPriority = 0 | 1 | 2; +/** + * Todo 待办项(与 CloudTaskItem DTO 一一对应)。 + * - 主键 id 为 Guid 序列化后的字符串 + * - 包含 ABP 审计字段,用于云同步冲突判断 + */ export interface Task { - id: number; + // === 业务字段 === + /** 主键(Guid 序列化) */ + id: string; + /** 标题 */ title: string; + /** 优先级 */ priority: TaskPriority; + /** 是否完成 */ isCompleted: boolean; - createdAt: string; - updatedAt: string; - parentTaskId?: number; + /** 任务编号(由后端自动分配,用户级自增字符串) */ + code: string; + /** 父任务 ID(Guid 类型,null 表示顶级任务) */ + parentTaskId: string | null; + + // === ABP 审计字段 === + /** 创建时间(服务端分配) */ + creationTime: string; + /** 创建人 ID */ + creatorId: string | null; + /** 最后修改时间(用于 LWW 冲突判断) */ + lastModificationTime: string | null; + /** 最后修改人 ID */ + lastModifierId: string | null; + /** 软删除标记(前端本地过滤不展示) */ + isDeleted: boolean; + /** 删除时间(不为 null 时表示已逻辑删除) */ + deletionTime: string | null; + /** 删除人 ID */ + deleterId: string | null; + + // === 导航属性 === + /** 子任务列表 */ subTasks: Task[]; } +/** + * 创建 Todo 待办项请求(客户端 → 服务端,新建时不带 id) + */ export interface CreateTaskDto { title: string; priority: TaskPriority; - parentTaskId?: number; + /** 父任务 ID(Guid 字符串) */ + parentTaskId?: string; } +/** + * 更新 Todo 待办项请求(客户端 → 服务端,必须带 id) + */ export interface UpdateTaskDto { - id: number; + /** 主键(Guid 字符串) */ + id: string; title?: string; priority?: TaskPriority; + isCompleted?: boolean; } export interface ApiResponse { diff --git a/src/Hua.Todo.Web/src/utils/guid.ts b/src/Hua.Todo.Web/src/utils/guid.ts new file mode 100644 index 0000000..b432221 --- /dev/null +++ b/src/Hua.Todo.Web/src/utils/guid.ts @@ -0,0 +1,19 @@ +/** + * Guid 工具函数。 + * 用于客户端创建临时任务 ID 和验证 Guid 格式。 + */ + +/** + * 生成新的 Guid 字符串(客户端创建临时任务时使用)。 + * 格式:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx(小写) + */ +export function generateGuid(): string { + return crypto.randomUUID(); +} + +/** + * 判断字符串是否为有效 Guid 格式。 + */ +export function isValidGuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +}