# 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 鉴权流程 1. 从 `Authorization` 头提取 Bearer Token(GUID 格式 SessionId) 2. 查 `UserSessions` 表:校验 SessionId 存在且 `ExpiresAtUtc > DateTime.UtcNow` 3. 加载用户角色,通过 `IRolePermissionMapper` 获取权限列表 4. 检查 `SecurityPolicies.AllowSync`:若为 `false`,从权限中移除 `sync:write` 5. 构造 `ClaimsIdentity`(含 `sub`=UserId、`role`、`perm` 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: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 关于"服务端为准"的正确理解 "服务端为准"并不意味着客户端会丢失数据。完整的同步流程是: 1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器 2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据 3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地 由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。 真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。 ### 6.3 SyncRequest / SyncResponse 结构 **请求**(`POST /sync`): ```json { "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 数组 **响应**: ```json { "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 变更检测(增量同步核心) 本地维护 `pendingUpserts` 和 `pendingDeletes` 两个集合: | 操作 | 记录时机 | 记录内容 | |---|---|---| | 新增任务 | 创建时 | `{ 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)` - 清空 `pendingUpserts` 和 `pendingDeletes` 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}` - 统一错误响应: ```json { "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](../project/产品需求文档-1.2.0.md) > - 研发工单总览:[00-工单总览.md](../project/研发工单-v1.2.0/00-工单总览.md) > - 服务端基础能力:[04-CloudSync-服务端基础能力.md](../project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md) > - 客户端工作流:[05-CloudSync-客户端配置与工作流.md](../project/研发工单-v1.2.0/05-CloudSync-客户端配置与工作流.md) > - 安全与可控落盘:[06-CloudSync-安全与可控落盘.md](../project/研发工单-v1.2.0/06-CloudSync-安全与可控落盘.md) > - 安全设计方案:[06.1-CloudSync-服务端安全设计方案.md](../project/研发工单-v1.2.0/06.1-CloudSync-服务端安全设计方案.md) > - 同源 Host 重构:[08-cloud_sync_refactor_plan.md](../project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md) > - 技术设计文档:[技术设计文档.md](./01-技术设计文档.md)