Files
Hua.Todo/docs/project/研发工单-v1.2.0/09-CloudSync-同步策略改进方案.md
ShaoHua 14868c45c7 feat: 实现v1.2.0云同步与实体重构核心功能
1.  重构用户与任务实体:UserEntity实现IUser<Guid>,TaskEntity继承ABP风格FullAuditedEntityWithUser,主键从int改为Guid
2.  新增云同步代理系统:嵌入式WebServer支持CloudSyncProxy转发云同步请求,新增配置API与持久化
3.  完善前端适配:新增Guid工具函数,更新任务类型定义与API交互逻辑,调整云同步设置弹窗适配本地代理
4.  文档与配置优化:更新文档结构,新增部署文档、版本记录,统一各项目配置项
5.  补充测试与迁移:新增单元测试,更新EF Core数据库迁移快照
2026-06-14 04:51:22 +08:00

23 KiB
Raw Permalink Blame History

CloudSync 同步策略改进方案

研发工单序号:09 依赖:04-CloudSync-服务端基础能力、05-CloudSync-客户端配置与工作流 状态:待实现


一、背景与问题

当前同步规则(02-任务同步规则.md 6.2 节)存在以下问题:

  1. TaskEntity 缺少 ABP 审计字段:未继承 ABP 基类,缺少 ExtraPropertiesConcurrencyStampCreationTimeCreatorIdLastModificationTimeLastModifierId 等标准字段
  2. 主键类型不符合 ABP 规范ABP 要求主键为 Guid,当前为 int
  3. 软删除字段缺失:没有 IsDeletedDeletionTime 字段,无法实现 Tombstone 逻辑删除

二、ABP 审计字段规范(必须遵守)

根据 表设计与字段命名规范.mdFullAuditedEntityWithUser<Guid, IdentityUser> 基类提供以下字段:

ABP 标准字段 含义 类型
Id 主键 GuidABP 强制要求)
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 中不可编辑IsDeletedCreationTimeCreatorIdLastModificationTimeLastModifierIdDeletionTimeDeleterId


三、TaskEntity 重构方案

3.1 数据库表名(ABP 规范)

ABP 框架约定表名格式为 T_{实体名}s,例如实体 TaskEntity 对应表名 T_Tasks。 EF Core 迁移脚本中的 table: "Tasks" 为 DbContext 配置的表名,两者须保持一致。

3.2 继承关系变更

// 原
public class TaskEntity
{
    public int Id { get; set; }
    // ...
}

// 改后
public class TaskEntity : FullAuditedEntityWithUser<Guid, IdentityUser>
{
    // Id、ExtraProperties、ConcurrencyStamp、CreationTime、CreatorId、
    // LastModificationTime、LastModifierId、IsDeleted、DeletionTime、DeleterId
    // 均由基类提供
}

3.2 业务字段保留

业务字段 类型 说明
UserId Guid 任务所属用户(云端隔离),本地为 "local"
Title string 任务标题
Priority TaskPriority 优先级枚举
IsCompleted bool 是否完成
ParentTaskId Guid? 父任务ID(外键)

ParentTaskId 类型变更:从 int? 改为 Guid?,与主键类型一致。

3.3 导航属性

public class TaskEntity : FullAuditedEntityWithUser<Guid, IdentityUser>
{
    public UserEntity? User { get; set; }
    public TaskEntity? ParentTask { get; set; }
    public List<TaskEntity> SubTasks { get; set; } = new();
}

四、DTO 重构方案(与数据库保持一致)

4.1 CloudTaskItemC# → JSON 响应)

// src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs

/// <summary>
/// 云同步任务条目(ABP 标准)。
/// </summary>
public class CloudTaskItem
{
    /// <summary>
    /// 任务 ID(服务端分配,Guid)。
    /// </summary>
    public Guid Id { get; set; }

    /// <summary>
    /// 标题。
    /// </summary>
    public string Title { get; set; } = string.Empty;

    /// <summary>
    /// 优先级。
    /// </summary>
    public TaskPriority Priority { get; set; }

    /// <summary>
    /// 是否完成。
    /// </summary>
    public bool IsCompleted { get; set; }

    /// <summary>
    /// 父任务 IDGuid)。
    /// </summary>
    public Guid? ParentTaskId { get; set; }

    // === ABP 审计字段 ===

    /// <summary>
    /// 创建时间。
    /// </summary>
    public DateTime CreationTime { get; set; }

    /// <summary>
    /// 创建人 ID。
    /// </summary>
    public Guid? CreatorId { get; set; }

    /// <summary>
    /// 最后修改时间(用于 LWW 冲突判断)。
    /// </summary>
    public DateTime? LastModificationTime { get; set; }

    /// <summary>
    /// 最后修改人 ID。
    /// </summary>
    public Guid? LastModifierId { get; set; }

    /// <summary>
    /// 软删除标记。
    /// </summary>
    public bool IsDeleted { get; set; }

    /// <summary>
    /// 删除时间。
    /// </summary>
    public DateTime? DeletionTime { get; set; }

    /// <summary>
    /// 删除人 ID。
    /// </summary>
    public Guid? DeleterId { get; set; }
}

4.2 CloudTaskUpsertC# → JSON 请求)

/// <summary>
/// 任务 Upsert DTO(客户端提交)。
/// </summary>
public class CloudTaskUpsert
{
    /// <summary>
    /// 任务 ID;为 null 表示新建。
    /// </summary>
    public Guid? Id { get; set; }

    /// <summary>
    /// 标题。
    /// </summary>
    public string Title { get; set; } = string.Empty;

    /// <summary>
    /// 优先级。
    /// </summary>
    public TaskPriority Priority { get; set; } = TaskPriority.Medium;

    /// <summary>
    /// 是否完成。
    /// </summary>
    public bool IsCompleted { get; set; }

    /// <summary>
    /// 父任务 ID(可选)。
    /// </summary>
    public Guid? ParentTaskId { get; set; }

    /// <summary>
    /// 客户端发起变更时写入的时间戳(UTC),用于 LWW 冲突判断。
    /// </summary>
    public DateTime? LastModificationTime { get; set; }
}

4.3 SyncRequest / SyncResponse

/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
    /// <summary>
    /// 新增或更新的任务列表。
    /// </summary>
    public List<CloudTaskUpsert> Upserts { get; set; } = new();

    /// <summary>
    /// 需要逻辑删除的任务 ID 列表。
    /// </summary>
    public List<Guid> Deletes { get; set; } = new();
}

/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
    /// <summary>
    /// 服务端时间(UTC)。
    /// </summary>
    public DateTime ServerTimeUtc { get; set; }

    /// <summary>
    /// 当前用户的任务全量(含 Tombstone)。
    /// </summary>
    public List<CloudTaskItem> Tasks { get; set; } = new();
}

4.4 JSON 序列化配置

DTO 默认使用 PascalCaseABP 标准),通过 [JsonPropertyName] 特性控制 JSON 输出:

public class CloudTaskItem
{
    [JsonPropertyName("id")]
    public Guid Id { get; set; }

    [JsonPropertyName("title")]
    public string Title { get; set; }

    // ... 其他字段
}

或通过全局配置启用 camelCase:

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 目标定义

// 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 前端变更检测逻辑

// 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 响应处理

// src/Hua.Todo.Web/src/api/cloudSync.ts

import type { Task } from '@/types/task';

/**
 * 获取云端全量任务(含 Tombstone)。
 * 返回数据直接赋值给本地 store,无需字段转换。
 */
export async function fetchCloudTasks(): Promise<Task[]> {
  const response = await cloudSyncApi.getTasks();
  // response.data 类型已是 Task[],服务端返回的 Guid 已序列化为 string
  return response.data;
}

/**
 * 拉取全量后,本地过滤不展示已删除任务。
 */
export function filterVisibleTasks(tasks: Task[]): Task[] {
  return tasks.filter(task => task.deletionTime === null);
}

5.5 pendingUpserts / pendingDeletes 类型修正

// src/Hua.Todo.Web/src/stores/taskStore.ts(示例)

import type { Task } from '@/types/task';

// 待上传的脏任务(key: task.id,即 Guid string
const pendingUpserts = reactive(new Map<string, Task>());

// 待上传的删除任务 IDGuid string
const pendingDeletes = reactive(new Set<string>());

5.6 Guid 字符串处理工具

// 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 迁移命令

cd src/Hua.Todo.Application
dotnet ef migrations add MakeTaskEntityAbpCompatible --startup-project ../Hua.Todo.Host

6.2 迁移内容预期

此迁移涉及大量字段变更,建议分阶段执行或使用"双写/兼容期"策略:

// MakeTaskEntityAbpCompatible.cs
// 表名:T_TasksABP 规范),DbContext 配置为 "Tasks"

protected override void Up(MigrationBuilder migrationBuilder)
{
    // 1. 新增 Guid Id 列(临时名)
    migrationBuilder.AddColumn<Guid>(
        name: "NewId",
        table: "T_Tasks",
        type: "TEXT",
        nullable: false,
        defaultValue: Guid.NewGuid());

    // 2. 将旧 int Id 数据迁移到 NewId
    // (需要手动 SQL 脚本或数据迁移)

    // 3. 删除旧 Id 列,重命名 NewId 为 Id
    migrationBuilder.DropColumn("Id", "T_Tasks");
    migrationBuilder.RenameColumn("NewId", "T_Tasks", "Id");

    // 4. 新增 ABP 审计字段
    migrationBuilder.AddColumn<string>(
        name: "ExtraProperties",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    migrationBuilder.AddColumn<string>(
        name: "ConcurrencyStamp",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    migrationBuilder.AddColumn<DateTime>(
        name: "CreationTime",
        table: "T_Tasks",
        type: "TEXT",
        nullable: false,
        defaultValue: DateTime.UtcNow);

    migrationBuilder.AddColumn<Guid?>(
        name: "CreatorId",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    migrationBuilder.AddColumn<DateTime?>(
        name: "LastModificationTime",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    migrationBuilder.AddColumn<Guid?>(
        name: "LastModifierId",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    migrationBuilder.AddColumn<bool>(
        name: "IsDeleted",
        table: "T_Tasks",
        type: "INTEGER",
        nullable: false,
        defaultValue: false);

    migrationBuilder.AddColumn<DateTime?>(
        name: "DeletionTime",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    migrationBuilder.AddColumn<Guid?>(
        name: "DeleterId",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);

    // 5. ParentTaskId 从 int 改为 Guid
    migrationBuilder.DropColumn("ParentTaskId", "T_Tasks");
    migrationBuilder.AddColumn<Guid?>(
        name: "ParentTaskId",
        table: "T_Tasks",
        type: "TEXT",
        nullable: true);
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    // 回滚逻辑...
}

注意:此迁移为破坏性变更,建议在离线环境充分测试后再部署到生产环境。


七、同步策略(与 02-任务同步规则.md 保持一致)

本章节同步策略逻辑完全遵循 02-任务同步规则.md 6.2 节,字段名使用 ABP 标准。

7.1 核心原则

  • 任务唯一标识是 idid 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
  • lastModificationTime 是冲突判断字段:用于解决"同一任务被多端修改时哪个变更更新"的问题
  • 服务端存储唯一真实源(Source of Truth:所有设备最终都收敛到服务端数据
  • 字段级 Last-Write-WinsLWW)合并:同一字段的多设备并发修改,以 lastModificationTime 较新者为准
  • 逻辑删除(Tombstone:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致
  • 增量同步:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量

7.2 变更追踪机制

每个 CloudTaskItem 包含以下时间戳字段:

ABP 字段 说明
creationTime 创建时间(服务端分配,永不改变)
lastModificationTime 最后一次修改时间(每次字段变更时更新)
isDeleted 软删除标记
deletionTime 逻辑删除时间(为 null 表示未删除)
creatorId 创建人 ID
lastModifierId 最后修改人 ID

lastModificationTime请求发起方(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。

7.3 冲突解决规则

当客户端提交的任务与服务端现有任务发生 id 冲突时,按以下规则处理:

场景 解决规则
同一 id,客户端 lastModificationTime 更新 服务端接受客户端版本,覆盖对应字段
同一 id,服务端 lastModificationTime 更新 服务端拒绝客户端版本,保留服务端版本
同一 id,两者 lastModificationTime 相同 服务端优先(保守策略)

字段级合并示例

  • 设备 A 修改标题,设备 B 修改优先级
  • 服务端对两个字段分别取 lastModificationTime 较新者,合并出最终结果
  • 两个设备的修改都被保留,不会互相覆盖

7.4 父子任务处理

  • 删除:删除父任务时,其子任务一并标记为已删除(递归)
  • 创建:子任务的 parentTaskId 在上传时若为 null(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射
  • 重建父子关系:若原父任务被删除后重建,子任务的 parentTaskId 引用仍指向原父 ID,不会自动指向新父

7.5 Tombstone 保留策略

  • deletionTimenull 的任务在服务端保留至少 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 查询过滤

// 基类已自动过滤 IsDeleted = true 的记录
// 但若需要显式查询(含已删除),使用 _context.Tasks.IgnoreQueryFilters()

9.2 CloudTaskSyncService 逻辑删除处理

// POST /sync 处理 deletes
foreach (var id in request.Deletes)
{
    var task = await _context.Tasks.FindAsync(id);
    if (task != null && !task.IsDeleted)
    {
        await _taskRepository.DeleteAsync(task); // 调用基类软删除
    }
}

十、验收标准

  1. TaskEntity 继承 FullAuditedEntityWithUser<Guid, IdentityUser>
  2. 主键类型从 int 迁移到 Guid
  3. EF Core 迁移成功执行,新增 ABP 审计字段
  4. 服务端 /tasks 端点返回 ABP 标准字段
  5. 服务端 /sync 端点正确处理软删除
  6. 前端正确适配 Guid 类型和 ABP 字段
  7. 同步规则文档(02-任务同步规则.md)字段命名已修正
  8. 数据模型约束文档(03-数据模型与迁移约束.md)已更新

十一、风险与缓解

风险 缓解措施
主键从 int 变 Guid 为破坏性迁移 使用双写/兼容期策略;提供数据迁移脚本
迁移破坏嵌入式宿主启动 在 MAUI/Avalonia 上验证 Database.Migrate() 不报错
前端类型变更导致编译错误 同步更新 TypeScript 类型定义
旧客户端不兼容新 API API 版本化;新字段为可选/默认值

回滚方案

  • 迁移回滚:dotnet ef migrations remove(可能丢失数据)
  • 代码回滚:恢复 TaskEntity 原定义

十二、Touch List

文件路径 改动类型 共享文件
src/Hua.Todo.Core/Entities/TaskEntity.cs 重构(继承 ABP 基类)
src/Hua.Todo.Application/CloudSync/Models/TaskSyncDtos.cs DTO 字段更新(新增 ABP 审计字段)
src/Hua.Todo.Application/Data/TodoDbContext.cs 查询过滤器配置
src/Hua.Todo.Application/CloudSync/CloudTaskSyncService.cs 软删除处理逻辑
src/Hua.Todo.Application/Migrations/ 新增迁移文件
src/Hua.Todo.Web/src/types/task.ts 重构(Guid 主键 + ABP 审计字段 + 类型修正) 前端共享
src/Hua.Todo.Web/src/api/cloudSync.ts API 响应类型对齐
src/Hua.Todo.Web/src/stores/taskStore.ts pendingUpserts/pendingDeletes 类型修正
src/Hua.Todo.Web/src/utils/guid.ts 新增 Guid 工具函数
docs/manual/02-任务同步规则.md 文档修正(字段命名) 是(文档)
.trae/rules/项目/03-数据模型与迁移约束.md 实体清单更新 是(规则)

十三、与其他工单的边界

工单 边界
04-CloudSync-服务端基础能力 依赖本工单的 ABP 审计字段
05-CloudSync-客户端配置与工作流 依赖本工单的 DTO 字段更新
08-同源 Host 重构 无依赖,可并行

建议:本工单与 04/05 存在依赖关系,建议在 04/05 之前完成,或合并为一个工单实现。


相关文档