# 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)