Files
Hua.Todo/docs/manual/08-云同步规则.md
ShaoHua 65cee20006 docs: 重组 docs/manual/ 指南结构,区分普通用户与开发者双入口
- 新增 00-目录与导读.md 双入口导航

- 用户面(01-04):项目介绍、安装指南、版本记录、其他信息

- 开发者面(05-10):技术栈、构建、架构、云同步、代码规范、MCP

- 拆分旧01为 01(用户)+05(开发者);旧02为 02(用户)+06(开发者)

- 合并旧08+09 MCP文档为 10-MCP服务集成

- 同步更新 README.md 与 .trae/rules/项目/ 交叉引用
2026-06-16 01:46:46 +08:00

24 KiB
Raw Permalink Blame History

Todo 待办项云同步规则

本文档面向开发者,汇总 Hua.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 / AvaloniaMauiProgram.cs / AvaloniaProgram.cs 只能调用 AddApplicationServices(),禁止调用 AddCloudSyncServer()
  • Hua.Todo.HostProgram.cs 同时调用两者。

1.3 同源 Host 改造(研发工单 08)

前端云同步请求统一走当前 Host 同源,不再直连外部 serverUrl

  • Host 模式(Vite devVite 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.SessionIdGUID),存储于服务端 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 TokenGUID 格式 SessionId
  2. UserSessions 表:校验 SessionId 存在且 ExpiresAtUtc > DateTime.UtcNow
  3. 加载用户角色,通过 IRolePermissionMapper 获取权限列表
  4. 检查 SecurityPolicies.AllowSync:若为 false,从权限中移除 sync:write
  5. 构造 ClaimsIdentity(含 sub=UserId、roleperm 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:readtasks:writesync:writepolicy:read
readonly tasks:readpolicy:read
nosync tasks:readtasks:writepolicy: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 核心原则

  • 任务唯一标识是 idid 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
  • updatedAtUtc 是冲突判断字段:用于解决"同一任务被多端修改时哪个变更更新"的问题
  • 服务端存储唯一真实源(Source of Truth:所有设备最终都收敛到服务端数据
  • 字段级 Last-Write-WinsLWW)合并:同一字段的多设备并发修改,以 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 保留策略

  • deletedAtUtcnull 的任务在服务端保留至少 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):

{
  "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 为服务端已知 IDid > 0)时执行更新;idnull / 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 前置检查

  1. 检查 isSyncing 状态,防止并发重复调用
  2. 检查登录状态(未登录则提示先登录)

6.4.2 变更检测(增量同步核心)

本地维护 pendingUpsertspendingDeletes 两个集合:

操作 记录时机 记录内容
新增任务 创建时 { 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)
    • 清空 pendingUpsertspendingDeletes
  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}
  • 统一错误响应:
{ "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.tsAxios 实例)

  • 与通用 client.tsbaseURL 含 /api)解耦,云同步专用。
  • 请求拦截器MAUI 模式下从 CloudSyncStorage 读取 serverUrlHost 模式不设 baseURL 走同源;自动附加 Bearer {accessToken}
  • 响应拦截器
    • 401 → 清除本地会话,弹出重新登录提示。
    • 403 FORBIDDEN → Toast 提示权限不足。
    • 其他错误 → 统一 Toast 通知。

8.2 cloudSync.tsAPI 封装)

提供 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.vueUI 组件)

功能区块:

  • 同步开关:checkbox 控制,校验地址已保存方可开启。
  • 服务端地址:输入 + 规范化 + 保存并探测(probeServerUrl)。
  • 登录区:用户名/密码 → login() → 刷新策略与状态。
  • 已登录区:会话摘要、连接状态、安全策略展示、登出 + 立即同步。
  • 通过 CustomEventcloudSyncStateChanged / 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 限制目标为公网地址,禁止内网探测

相关文档