1. 重构用户与任务实体:UserEntity实现IUser<Guid>,TaskEntity继承ABP风格FullAuditedEntityWithUser,主键从int改为Guid 2. 新增云同步代理系统:嵌入式WebServer支持CloudSyncProxy转发云同步请求,新增配置API与持久化 3. 完善前端适配:新增Guid工具函数,更新任务类型定义与API交互逻辑,调整云同步设置弹窗适配本地代理 4. 文档与配置优化:更新文档结构,新增部署文档、版本记录,统一各项目配置项 5. 补充测试与迁移:新增单元测试,更新EF Core数据库迁移快照
23 KiB
CloudSync 同步策略改进方案
研发工单序号:09 依赖:04-CloudSync-服务端基础能力、05-CloudSync-客户端配置与工作流 状态:待实现
一、背景与问题
当前同步规则(02-任务同步规则.md 6.2 节)存在以下问题:
- TaskEntity 缺少 ABP 审计字段:未继承 ABP 基类,缺少
ExtraProperties、ConcurrencyStamp、CreationTime、CreatorId、LastModificationTime、LastModifierId等标准字段 - 主键类型不符合 ABP 规范:ABP 要求主键为
Guid,当前为int - 软删除字段缺失:没有
IsDeleted和DeletionTime字段,无法实现 Tombstone 逻辑删除
二、ABP 审计字段规范(必须遵守)
根据 表设计与字段命名规范.md,FullAuditedEntityWithUser<Guid, IdentityUser> 基类提供以下字段:
| ABP 标准字段 | 含义 | 类型 |
|---|---|---|
Id |
主键 | Guid(ABP 强制要求) |
ExtraProperties |
扩展属性字典 | ExtraPropertyDictionary |
ConcurrencyStamp |
并发戳,用于乐观并发控制 | string? |
CreationTime |
创建时间 | DateTime |
CreatorId |
创建人Id | Guid? |
LastModificationTime |
最后修改时间 | DateTime? |
LastModifierId |
最后修改人Id | Guid? |
IsDeleted |
软删除标记 | bool |
DeletionTime |
删除时间 | DateTime? |
DeleterId |
删除人Id | Guid? |
字段忽略规则(ShouldIgnoreList)
以下字段在实体中不生成任何属性:
| 字段名 | 理由 |
|---|---|
ExtraProperties |
ABP 框架扩展属性,基类已处理 |
不可编辑字段(NotEnableEditList)
以下字段在更新 DTO 中不可编辑:IsDeleted、CreationTime、CreatorId、LastModificationTime、LastModifierId、DeletionTime、DeleterId
三、TaskEntity 重构方案
3.1 数据库表名(ABP 规范)
ABP 框架约定表名格式为
T_{实体名}s,例如实体TaskEntity对应表名T_Tasks。 EF Core 迁移脚本中的table: "Tasks"为 DbContext 配置的表名,两者须保持一致。
3.2 继承关系变更
// 原
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 CloudTaskItem(C# → 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>
/// 父任务 ID(Guid)。
/// </summary>
public Guid? ParentTaskId { get; set; }
// === ABP 审计字段 ===
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreationTime { get; set; }
/// <summary>
/// 创建人 ID。
/// </summary>
public Guid? CreatorId { get; set; }
/// <summary>
/// 最后修改时间(用于 LWW 冲突判断)。
/// </summary>
public DateTime? LastModificationTime { get; set; }
/// <summary>
/// 最后修改人 ID。
/// </summary>
public Guid? LastModifierId { get; set; }
/// <summary>
/// 软删除标记。
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// 删除时间。
/// </summary>
public DateTime? DeletionTime { get; set; }
/// <summary>
/// 删除人 ID。
/// </summary>
public Guid? DeleterId { get; set; }
}
4.2 CloudTaskUpsert(C# → JSON 请求)
/// <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 默认使用 PascalCase(ABP 标准),通过 [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>());
// 待上传的删除任务 ID(Guid 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_Tasks(ABP 规范),DbContext 配置为 "Tasks"
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. 新增 Guid Id 列(临时名)
migrationBuilder.AddColumn<Guid>(
name: "NewId",
table: "T_Tasks",
type: "TEXT",
nullable: false,
defaultValue: Guid.NewGuid());
// 2. 将旧 int Id 数据迁移到 NewId
// (需要手动 SQL 脚本或数据迁移)
// 3. 删除旧 Id 列,重命名 NewId 为 Id
migrationBuilder.DropColumn("Id", "T_Tasks");
migrationBuilder.RenameColumn("NewId", "T_Tasks", "Id");
// 4. 新增 ABP 审计字段
migrationBuilder.AddColumn<string>(
name: "ExtraProperties",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ConcurrencyStamp",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreationTime",
table: "T_Tasks",
type: "TEXT",
nullable: false,
defaultValue: DateTime.UtcNow);
migrationBuilder.AddColumn<Guid?>(
name: "CreatorId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime?>(
name: "LastModificationTime",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid?>(
name: "LastModifierId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "T_Tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime?>(
name: "DeletionTime",
table: "T_Tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid?>(
name: "DeleterId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
// 5. ParentTaskId 从 int 改为 Guid
migrationBuilder.DropColumn("ParentTaskId", "T_Tasks");
migrationBuilder.AddColumn<Guid?>(
name: "ParentTaskId",
table: "T_Tasks",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// 回滚逻辑...
}
注意:此迁移为破坏性变更,建议在离线环境充分测试后再部署到生产环境。
七、同步策略(与 02-任务同步规则.md 保持一致)
本章节同步策略逻辑完全遵循 02-任务同步规则.md 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 关于"服务端为准"的正确理解
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
- 客户端上传阶段:将
pendingUpserts+pendingDeletes提交到服务器 - 服务器合并阶段:按 LWW 规则合并客户端提交与服务器现有数据
- 客户端覆盖阶段:用服务器返回的全量数据覆盖本地
由于客户端在第 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); // 调用基类软删除
}
}
十、验收标准
- TaskEntity 继承
FullAuditedEntityWithUser<Guid, IdentityUser> - 主键类型从
int迁移到Guid - EF Core 迁移成功执行,新增 ABP 审计字段
- 服务端
/tasks端点返回 ABP 标准字段 - 服务端
/sync端点正确处理软删除 - 前端正确适配 Guid 类型和 ABP 字段
- 同步规则文档(02-任务同步规则.md)字段命名已修正
- 数据模型约束文档(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
- 同步规则:02-任务同步规则.md
- 数据模型约束:03-数据模型与迁移约束.md