1. 重构用户与任务实体:UserEntity实现IUser<Guid>,TaskEntity继承ABP风格FullAuditedEntityWithUser,主键从int改为Guid 2. 新增云同步代理系统:嵌入式WebServer支持CloudSyncProxy转发云同步请求,新增配置API与持久化 3. 完善前端适配:新增Guid工具函数,更新任务类型定义与API交互逻辑,调整云同步设置弹窗适配本地代理 4. 文档与配置优化:更新文档结构,新增部署文档、版本记录,统一各项目配置项 5. 补充测试与迁移:新增单元测试,更新EF Core数据库迁移快照
24 KiB
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 鉴权流程
- 从
Authorization头提取 Bearer Token(GUID 格式 SessionId) - 查
UserSessions表:校验 SessionId 存在且ExpiresAtUtc > DateTime.UtcNow - 加载用户角色,通过
IRolePermissionMapper获取权限列表 - 检查
SecurityPolicies.AllowSync:若为false,从权限中移除sync:write - 构造
ClaimsIdentity(含sub=UserId、role、permClaims),注入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 关于"服务端为准"的正确理解
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
- 客户端上传阶段:将
pendingUpserts+pendingDeletes提交到服务器 - 服务器合并阶段:按 LWW 规则合并客户端提交与服务器现有数据
- 客户端覆盖阶段:用服务器返回的全量数据覆盖本地
由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——不会丢失任何已提交的变更。
真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 pendingUpserts 中,下次联网同步时会正常提交。
6.3 SyncRequest / SyncResponse 结构
请求(POST /sync):
{
"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 数组
响应:
{
"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 前置检查
- 检查
isSyncing状态,防止并发重复调用 - 检查登录状态(未登录则提示先登录)
6.4.2 变更检测(增量同步核心)
本地维护 pendingUpserts 和 pendingDeletes 两个集合:
| 操作 | 记录时机 | 记录内容 |
|---|---|---|
| 新增任务 | 创建时 | { id: 临时负数, title, priority, ..., updatedAtUtc: 当前时间 } |
| 修改任务 | 任何字段变更时 | { id, 全部字段, updatedAtUtc: 当前时间 } |
| 删除任务 | 点击删除时 | { id }(从 pendingUpserts 移除,加入 pendingDeletes) |
每次用户操作后,更新本地
updatedAtUtc。使用seenIds集合去重,同一id只保留最新一条记录。
6.4.3 数据准备
-
合并 pendingUpserts:
- 调用
flattenTasksToCloudUpserts(localTasks, pendingUpserts)生成 upsert 列表 - ID 映射规则:
id > 0:保留原 ID(服务端已知,执行更新)id <= 0或未设置:置为null(服务端分配新 ID,执行创建)
- 按
updatedAtUtc升序排列(较早的先发送,便于服务端 LWW 判断)
- 调用
-
生成 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 响应处理
-
解析全量响应:
- 调用
buildTaskTreeFromCloudItems(response.tasks)将扁平响应转为前端树结构 - 过滤
deletedAtUtc == null的任务用于展示
- 调用
-
更新本地状态:
tasks.value = cloudTasks(覆盖式更新,确保与服务端一致)LocalStorageService.saveTasks(cloudTasks)- 清空
pendingUpserts和pendingDeletes
-
更新同步状态:
lastSyncTime:使用响应中的serverTimeUtcsyncError = 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} - 统一错误响应:
{ "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
- 研发工单总览:00-工单总览.md
- 服务端基础能力:04-CloudSync-服务端基础能力.md
- 客户端工作流:05-CloudSync-客户端配置与工作流.md
- 安全与可控落盘:06-CloudSync-安全与可控落盘.md
- 安全设计方案:06.1-CloudSync-服务端安全设计方案.md
- 同源 Host 重构:08-cloud_sync_refactor_plan.md
- 技术设计文档:技术设计文档.md