feat: 实现v1.2.0云同步与实体重构核心功能

1.  重构用户与任务实体:UserEntity实现IUser<Guid>,TaskEntity继承ABP风格FullAuditedEntityWithUser,主键从int改为Guid
2.  新增云同步代理系统:嵌入式WebServer支持CloudSyncProxy转发云同步请求,新增配置API与持久化
3.  完善前端适配:新增Guid工具函数,更新任务类型定义与API交互逻辑,调整云同步设置弹窗适配本地代理
4.  文档与配置优化:更新文档结构,新增部署文档、版本记录,统一各项目配置项
5.  补充测试与迁移:新增单元测试,更新EF Core数据库迁移快照
This commit is contained in:
ShaoHua
2026-06-14 04:51:22 +08:00
parent d81aa06681
commit 14868c45c7
51 changed files with 4368 additions and 449 deletions
+6 -6
View File
@@ -179,12 +179,12 @@ Hua.Todo/
## 📚 更多文档 ## 📚 更多文档
### 用户与开发者手册 ### 用户与开发者手册
- [技术栈与模块说明](docs/manual/技术栈与模块.md) - [技术栈与模块说明](docs/manual/03-技术栈与模块.md)
- [版本更新历史](docs/manual/版本记录.md) - [版本更新历史](docs/manual/06-版本记录.md)
- [技术设计文档](docs/manual/技术设计文档.md) - [技术设计文档](docs/manual/01-技术设计文档.md)
- [代码规范文档](docs/manual/代码规范文档.md) - [代码规范文档](docs/manual/04-代码规范文档.md)
- [部署文档](docs/manual/部署文档.md) - [部署文档](docs/manual/05-部署文档.md)
- [其他信息](docs/manual/其他信息.md) - [其他信息](docs/manual/07-其他信息.md)
### 项目进度与需求 ### 项目进度与需求
- [产品需求文档](docs/project/产品需求文档.md) - [产品需求文档](docs/project/产品需求文档.md)
@@ -31,11 +31,11 @@
Hua.Todo/ Hua.Todo/
├── docs/ # 文档目录 ├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册 │ ├── manual/ # 用户/开发者手册
│ │ ├── 技术栈与模块.md │ │ ├── 03-技术栈与模块.md
│ │ ├── 版本记录.md │ │ ├── 06-版本记录.md
│ │ ├── 技术设计文档.md(本文件) │ │ ├── 01-技术设计文档.md(本文件)
│ │ ├── 代码规范文档.md │ │ ├── 04-代码规范文档.md
│ │ └── 部署文档.md │ │ └── 05-部署文档.md
│ └── project/ # 项目进度/需求文档 │ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md │ ├── 产品需求文档.md
│ └── ... │ └── ...
+512
View File
@@ -0,0 +1,512 @@
# 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 TokenGUID 格式 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-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 保留策略
- `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.tsAxios 实例)
- 与通用 `client.ts`baseURL 含 `/api`)解耦,云同步专用。
- **请求拦截器**:MAUI 模式下从 `CloudSyncStorage` 读取 `serverUrl`Host 模式不设 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()` → 刷新策略与状态。
- **已登录区**:会话摘要、连接状态、安全策略展示、登出 + 立即同步。
- 通过 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)
@@ -9,6 +9,10 @@
- v1.1.0MAUI + WebView 跨平台版本 - v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能 - v1.2.0 (规划中)Linux 支持与增强功能
### v1.2.8 (2026-06-14)
- **文档**:新增 [任务同步规则.md](./02-任务同步规则.md),汇总 Todo 待办项云同步的架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等完整规则。
### v1.2.8 (2026-04-13) ### v1.2.8 (2026-04-13)
- **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。 - **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。
@@ -35,8 +39,8 @@
- **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。 - **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。 - **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。 - **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
- **文档与部署指南**:新增 `docs/manual/部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。 - **文档与部署指南**:新增 `docs/manual/05-部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。
- **用户文档完善**:在规划中新增了 `docs/manual/新手指南.md``docs/manual/用户指南.md` - **用户文档完善**:在规划中新增了 `docs/manual/08-新手指南.md``docs/manual/09-用户指南.md`
27→ 27→
28→### v1.1.1 (2026-04-06) 28→### v1.1.1 (2026-04-06)
@@ -43,6 +43,8 @@
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址校验+保存探测、登录/登出、登录后拉取云端 Todo 数据并在主界面只读展示 | | 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址校验+保存探测、登录/登出、登录后拉取云端 Todo 数据并在主界面只读展示 |
| 06 - 安全与落盘策略 | 未标注 | 待验证 | | | 06 - 安全与落盘策略 | 未标注 | 待验证 | |
| 07 - 文档与验收 | 未标注 | 待验证 | | | 07 - 文档与验收 | 未标注 | 待验证 | |
| 08 - 同源 Host 重构 | 已设计 | 待实现 | Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` |
| 09 - 同步策略改进 | 待实现 | 待验证 | 重构 TaskEntity 继承 ABP 基类(`FullAuditedEntityWithUser<Guid, IdentityUser>`),主键从 `int` 改为 `Guid`,新增 ABP 审计字段(ExtraProperties/ConcurrencyStamp/CreationTime/CreatorId/LastModificationTime/LastModifierId/IsDeleted/DeletionTime/DeleterId |
## 交付判定(v1.2.0 Done Definition ## 交付判定(v1.2.0 Done Definition
@@ -0,0 +1,734 @@
# CloudSync 同步策略改进方案
> 研发工单序号:09
> 依赖:04-CloudSync-服务端基础能力、05-CloudSync-客户端配置与工作流
> 状态:待实现
---
## 一、背景与问题
当前同步规则([02-任务同步规则.md](../../manual/02-任务同步规则.md) 6.2 节)存在以下问题:
1. **TaskEntity 缺少 ABP 审计字段**:未继承 ABP 基类,缺少 `ExtraProperties``ConcurrencyStamp``CreationTime``CreatorId``LastModificationTime``LastModifierId` 等标准字段
2. **主键类型不符合 ABP 规范**ABP 要求主键为 `Guid`,当前为 `int`
3. **软删除字段缺失**:没有 `IsDeleted``DeletionTime` 字段,无法实现 Tombstone 逻辑删除
---
## 二、ABP 审计字段规范(必须遵守)
根据 [表设计与字段命名规范.md](file:///d:/Codes/CodeSmith/CodeSmith/ShaoHua.CodeSmith.Abp/docs/2.表设计与字段命名规范.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 继承关系变更
```csharp
// 原
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 导航属性
```csharp
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 响应)
```csharp
// 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 请求)
```csharp
/// <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
```csharp
/// <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 输出:
```csharp
public class CloudTaskItem
{
[JsonPropertyName("id")]
public Guid Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
// ... 其他字段
}
```
或通过全局配置启用 camelCase:
```csharp
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
```
---
## 五、前端类型定义(与 DTO 保持一致)
### 5.1 当前状态 vs 目标状态对比
| 字段 | 当前类型 | 目标类型 | 变更说明 |
|---|---|---|---|
| `id` | `number` | `string` | 主键从 int 改为 GuidJSON 序列化为字符串 |
| `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 目标定义
```typescript
// 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 前端变更检测逻辑
```typescript
// 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 响应处理
```typescript
// 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 类型修正
```typescript
// 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 字符串处理工具
```typescript
// 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 迁移命令
```powershell
cd src/Hua.Todo.Application
dotnet ef migrations add MakeTaskEntityAbpCompatible --startup-project ../Hua.Todo.Host
```
### 6.2 迁移内容预期
此迁移涉及大量字段变更,建议分阶段执行或使用"双写/兼容期"策略:
```csharp
// 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](../../manual/02-任务同步规则.md) 6.2 节,字段名使用 ABP 标准。
### 7.1 核心原则
- **任务唯一标识是 `id`**`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
- **`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 保留策略
- `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 关于"服务端为准"的正确理解
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器
2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据
3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地
由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。
真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。
---
## 九、服务端代码改动
### 9.1 TaskRepository 查询过滤
```csharp
// 基类已自动过滤 IsDeleted = true 的记录
// 但若需要显式查询(含已删除),使用 _context.Tasks.IgnoreQueryFilters()
```
### 9.2 CloudTaskSyncService 逻辑删除处理
```csharp
// 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 之前完成,或合并为一个工单实现。
---
> **相关文档**
> - ABP 表设计规范:[2.表设计与字段命名规范.md](file:///d:/Codes/CodeSmith/CodeSmith/ShaoHua.CodeSmith.Abp/docs/2.表设计与字段命名规范.md)
> - 同步规则:[02-任务同步规则.md](../../manual/02-任务同步规则.md)
> - 数据模型约束:[03-数据模型与迁移约束.md](../../../.trae/rules/项目/03-数据模型与迁移约束.md)
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Hua.Todo.Application.CloudSync.Auth; using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models; using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services; using Hua.Todo.Application.CloudSync.Services;
@@ -342,4 +344,36 @@ public static class CloudSyncEndpointExtensions
} }
#endregion #endregion
/// <summary>
/// 注册云同步代理设置 API(仅嵌入式 WebServer 使用)。
/// GET /api/cloud-sync/settings → 返回当前 URL
/// POST /api/cloud-sync/settings → 设置 URL
/// </summary>
public static WebApplication MapCloudSyncProxySettings(this WebApplication app)
{
var group = app.MapGroup("/api/cloud-sync").WithTags("CloudSync - Proxy");
group.MapGet("/settings", (CloudSyncProxySettings settings) =>
{
return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty });
});
group.MapPost("/settings", async (HttpContext context, CloudSyncProxySettings settings) =>
{
using var doc = await JsonDocument.ParseAsync(context.Request.Body);
var serverUrl = doc.RootElement.TryGetProperty("serverUrl", out var el) ? el.GetString() : null;
settings.SetUrl(serverUrl);
// 持久化到 appsettings.json
if (context.RequestServices.GetService<CloudSyncProxySettingsPersistence>() is { } persistence)
{
try { persistence.Save(settings.GetUrl() ?? string.Empty); } catch { /* 保存失败不影响前端 */ }
}
return Results.Json(new { serverUrl = settings.GetUrl() ?? string.Empty });
});
return app;
}
} }
@@ -0,0 +1,116 @@
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步反向代理中间件。
/// 将 /auth/、/tasks/、/sync/、/security/、/cloud-sync/ 请求转发到远程 Host。
/// 仅在嵌入式 MAUI/Avalonia WebServer 中注册。
/// </summary>
public class CloudSyncProxyMiddleware
{
private readonly RequestDelegate _next;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// 需要代理的路径前缀集合。
/// </summary>
private static readonly string[] ProxyPrefixes = { "/auth/", "/tasks/", "/sync/", "/security/", "/cloud-sync/" };
public CloudSyncProxyMiddleware(RequestDelegate next, IHttpClientFactory httpClientFactory)
{
_next = next;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// 处理请求。如果匹配代理前缀则转发,否则放行到下一中间件。
/// </summary>
public async Task InvokeAsync(HttpContext context)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
// 仅拦截云同步相关路径
if (!ProxyPrefixes.Any(p => requestPath.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
{
await _next(context);
return;
}
var settings = context.RequestServices.GetRequiredService<CloudSyncProxySettings>();
var targetUrl = settings.GetUrl();
if (string.IsNullOrEmpty(targetUrl))
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"error\":\"cloud sync server URL not configured\"}");
return;
}
await ProxyRequestAsync(context, targetUrl);
}
/// <summary>
/// 将当前请求转发到目标地址,并回写响应。
/// </summary>
private async Task ProxyRequestAsync(HttpContext context, string targetUrl)
{
var client = _httpClientFactory.CreateClient("CloudSyncProxy");
client.Timeout = TimeSpan.FromSeconds(30);
var request = context.Request;
var targetUri = $"{targetUrl}{request.Path}{request.QueryString}";
var proxyRequest = new HttpRequestMessage(new HttpMethod(request.Method), targetUri);
// 复制请求体
if (request.ContentLength > 0 || request.Headers.ContainsKey("Transfer-Encoding"))
{
request.EnableBuffering();
request.Body.Position = 0;
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
proxyRequest.Content = new StringContent(body, System.Text.Encoding.UTF8, request.ContentType ?? "application/json");
}
// 复制 Authorization 头
if (request.Headers.TryGetValue("Authorization", out var authHeader))
{
proxyRequest.Headers.TryAddWithoutValidation("Authorization", authHeader.ToArray());
}
// 复制 Content-TypeStringContent 已设置,但确保不被覆盖)
if (request.Headers.TryGetValue("Content-Type", out var ctHeader) && proxyRequest.Content != null)
{
proxyRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(ctHeader.First()!);
}
using var response = await client.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
context.Response.StatusCode = (int)response.StatusCode;
context.Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json";
// 回写响应体
var responseBody = await response.Content.ReadAsStringAsync(context.RequestAborted);
await context.Response.WriteAsync(responseBody, context.RequestAborted);
}
}
/// <summary>
/// CloudSyncProxyMiddleware 的 IApplicationBuilder 扩展。
/// </summary>
public static class CloudSyncProxyMiddlewareExtensions
{
/// <summary>
/// 注册云同步反向代理中间件。
/// </summary>
public static IApplicationBuilder UseCloudSyncProxy(this IApplicationBuilder builder)
{
return builder.UseMiddleware<CloudSyncProxyMiddleware>();
}
}
@@ -0,0 +1,34 @@
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步代理 URL 管理服务。
/// 嵌入式 WebServer 启动时从 appsettings 读取初始值,
/// 前端通过 /api/cloud-sync/settings 读写。
/// </summary>
public class CloudSyncProxySettings
{
private string? _url;
private readonly object _lock = new();
/// <summary>
/// 获取当前配置的云同步服务端地址,未配置时返回 null。
/// </summary>
public string? GetUrl()
{
lock (_lock)
{
return _url;
}
}
/// <summary>
/// 设置云同步服务端地址。
/// </summary>
public void SetUrl(string? url)
{
lock (_lock)
{
_url = string.IsNullOrWhiteSpace(url) ? null : url.TrimEnd('/');
}
}
}
@@ -0,0 +1,57 @@
using System.Text.Json;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步代理 URL 的 appsettings.json 持久化帮助类。
/// 由 MAUI/Avalonia 宿主创建并注册,注入 appsettings 文件路径。
/// </summary>
public class CloudSyncProxySettingsPersistence
{
private readonly string _settingsFilePath;
public CloudSyncProxySettingsPersistence(string settingsFilePath)
{
_settingsFilePath = settingsFilePath;
}
/// <summary>
/// 将 CloudSyncUrl 写回 appsettings.json。
/// </summary>
public void Save(string url)
{
var json = File.ReadAllText(_settingsFilePath);
using var doc = JsonDocument.Parse(json);
using var stream = new FileStream(_settingsFilePath, FileMode.Create, FileAccess.Write);
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.NameEquals("WebServer"))
{
writer.WritePropertyName("WebServer");
writer.WriteStartObject();
foreach (var wsProp in prop.Value.EnumerateObject())
{
writer.WritePropertyName(wsProp.Name);
if (wsProp.NameEquals("CloudSyncUrl"))
{
writer.WriteStringValue(url ?? string.Empty);
}
else
{
wsProp.Value.WriteTo(writer);
}
}
writer.WriteEndObject();
}
else
{
writer.WritePropertyName(prop.Name);
prop.Value.WriteTo(writer);
}
}
writer.WriteEndObject();
}
}
@@ -56,5 +56,29 @@ public static class CloudSyncServiceCollectionExtensions
return services; return services;
} }
/// <summary>
/// 注册云同步反向代理所需的服务(轻量级,供嵌入式 MAUI/Avalonia WebServer 使用)。
/// 仅注册 URL 管理服务与转发专用 HttpClient,不包含认证/授权/业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncProxy(this IServiceCollection services)
{
services.AddSingleton<CloudSyncProxySettings>();
// 代理专用的 HttpClient:不跟随重定向,允许转发到自签/内网服务器
services.AddHttpClient("CloudSyncProxy", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Proxy/1.0");
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
return services;
}
} }
@@ -1,46 +1,140 @@
using System.Text.Json.Serialization;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models; namespace Hua.Todo.Application.CloudSync.Models;
/// <summary> /// <summary>
/// 云同步任务条目。 /// 云同步任务条目ABP 标准)
/// </summary> /// </summary>
public class CloudTaskItem public class CloudTaskItem
{ {
/// <summary> /// <summary>
/// 任务 ID(服务端分配)。 /// 任务 ID(服务端分配Guid)。
/// </summary> /// </summary>
public int Id { get; set; } [JsonPropertyName("id")]
public Guid Id { get; set; }
/// <summary> /// <summary>
/// 标题。 /// 标题。
/// </summary> /// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 优先级。 /// 优先级。
/// </summary> /// </summary>
[JsonPropertyName("priority")]
public TaskPriority Priority { get; set; } public TaskPriority Priority { get; set; }
/// <summary> /// <summary>
/// 是否完成。 /// 是否完成。
/// </summary> /// </summary>
[JsonPropertyName("isCompleted")]
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }
/// <summary> /// <summary>
/// 创建时间(UTC)。 /// 任务编号(用户级自增字符串)。
/// </summary> /// </summary>
public DateTime CreatedAtUtc { get; set; } [JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 更新时间(UTC)。 /// 父任务 IDGuid)。
/// </summary> /// </summary>
public DateTime UpdatedAtUtc { get; set; } [JsonPropertyName("parentTaskId")]
public Guid? ParentTaskId { get; set; }
// === ABP 审计字段 ===
/// <summary> /// <summary>
/// 父任务 ID(v1.2.0 同步可先不使用) /// 创建时间
/// </summary> /// </summary>
public int? ParentTaskId { get; set; } [JsonPropertyName("creationTime")]
public DateTime CreationTime { get; set; }
/// <summary>
/// 创建人 ID。
/// </summary>
[JsonPropertyName("creatorId")]
public Guid? CreatorId { get; set; }
/// <summary>
/// 最后修改时间(用于 LWW 冲突判断)。
/// </summary>
[JsonPropertyName("lastModificationTime")]
public DateTime? LastModificationTime { get; set; }
/// <summary>
/// 最后修改人 ID。
/// </summary>
[JsonPropertyName("lastModifierId")]
public Guid? LastModifierId { get; set; }
/// <summary>
/// 软删除标记。
/// </summary>
[JsonPropertyName("isDeleted")]
public bool IsDeleted { get; set; }
/// <summary>
/// 删除时间。
/// </summary>
[JsonPropertyName("deletionTime")]
public DateTime? DeletionTime { get; set; }
/// <summary>
/// 删除人 ID。
/// </summary>
[JsonPropertyName("deleterId")]
public Guid? DeleterId { get; set; }
}
/// <summary>
/// 任务 Upsert DTO(客户端提交)。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为 null 表示新建。
/// </summary>
[JsonPropertyName("id")]
public Guid? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
[JsonPropertyName("priority")]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
[JsonPropertyName("isCompleted")]
public bool IsCompleted { get; set; }
/// <summary>
/// 任务编号。
/// </summary>
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
[JsonPropertyName("parentTaskId")]
public Guid? ParentTaskId { get; set; }
/// <summary>
/// 客户端发起变更时写入的时间戳(UTC),用于 LWW 冲突判断。
/// </summary>
[JsonPropertyName("lastModificationTime")]
public DateTime? LastModificationTime { get; set; }
} }
/// <summary> /// <summary>
@@ -51,43 +145,14 @@ public class SyncRequest
/// <summary> /// <summary>
/// 新增或更新的任务列表。 /// 新增或更新的任务列表。
/// </summary> /// </summary>
[JsonPropertyName("upserts")]
public List<CloudTaskUpsert> Upserts { get; set; } = new(); public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary> /// <summary>
/// 需要删除的任务 ID 列表。 /// 需要逻辑删除的任务 ID 列表。
/// </summary> /// </summary>
public List<int> Deletes { get; set; } = new(); [JsonPropertyName("deletes")]
} public List<Guid> Deletes { get; set; } = new();
/// <summary>
/// 任务 Upsert DTO。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为空表示新建。
/// </summary>
public int? 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 int? ParentTaskId { get; set; }
} }
/// <summary> /// <summary>
@@ -98,11 +163,12 @@ public class SyncResponse
/// <summary> /// <summary>
/// 服务端时间(UTC)。 /// 服务端时间(UTC)。
/// </summary> /// </summary>
[JsonPropertyName("serverTimeUtc")]
public DateTime ServerTimeUtc { get; set; } public DateTime ServerTimeUtc { get; set; }
/// <summary> /// <summary>
/// 当前用户的任务全量。 /// 当前用户的任务全量(含 Tombstone
/// </summary> /// </summary>
[JsonPropertyName("tasks")]
public List<CloudTaskItem> Tasks { get; set; } = new(); public List<CloudTaskItem> Tasks { get; set; } = new();
} }
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Hua.Todo.Application.CloudSync.Models; using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
@@ -7,6 +8,7 @@ namespace Hua.Todo.Application.CloudSync.Services;
/// <summary> /// <summary>
/// 云同步任务服务(按用户隔离)。 /// 云同步任务服务(按用户隔离)。
/// 实现 ABP 风格审计字段和 LWW 冲突解决策略。
/// </summary> /// </summary>
public class CloudTaskSyncService public class CloudTaskSyncService
{ {
@@ -22,17 +24,19 @@ public class CloudTaskSyncService
} }
/// <summary> /// <summary>
/// 获取指定用户的任务全量。 /// 获取指定用户的任务全量(含 Tombstone
/// </summary> /// </summary>
/// <param name="userId">用户 ID。</param> /// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param> /// <param name="cancellationToken">取消令牌。</param>
/// <returns>任务列表。</returns> /// <returns>任务列表(含已删除任务)。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken) public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{ {
// 使用 IgnoreQueryFilters 获取包括已删除任务的全量
var tasks = await _dbContext.Tasks var tasks = await _dbContext.Tasks
.AsNoTracking() .AsNoTracking()
.IgnoreQueryFilters()
.Where(t => t.UserId == userId) .Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreationTime)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return tasks.Select(MapToItem).ToList(); return tasks.Select(MapToItem).ToList();
@@ -40,6 +44,9 @@ public class CloudTaskSyncService
/// <summary> /// <summary>
/// 执行同步(增改删),并返回最新全量。 /// 执行同步(增改删),并返回最新全量。
/// - 对 deletes 递归标记为已删除(Tombstone)
/// - 对 upserts 按 lastModificationTime 降序处理,较新的先处理
/// - LWW 冲突解决:客户端 lastModificationTime >= 服务端时接受客户端版本
/// </summary> /// </summary>
/// <param name="userId">用户 ID。</param> /// <param name="userId">用户 ID。</param>
/// <param name="request">同步请求。</param> /// <param name="request">同步请求。</param>
@@ -49,132 +56,109 @@ public class CloudTaskSyncService
{ {
request ??= new SyncRequest(); request ??= new SyncRequest();
// 1. 处理删除(逻辑删除,Tombstone)
if (request.Deletes.Count > 0) if (request.Deletes.Count > 0)
{ {
var deleteIds = request.Deletes.Distinct().ToList(); var deleteIds = request.Deletes.Distinct().ToList();
var toDelete = await _dbContext.Tasks foreach (var id in deleteIds)
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
.ToListAsync(cancellationToken);
if (toDelete.Count > 0)
{ {
_dbContext.Tasks.RemoveRange(toDelete); await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken);
} }
// 保存删除操作
await _dbContext.SaveChangesAsync(cancellationToken);
} }
// 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理)
if (request.Upserts.Count > 0) if (request.Upserts.Count > 0)
{ {
// 去重:同一 Id 保留最后一条(后发送覆盖先发送) // 去重策略
var uniqueUpserts = request.Upserts // - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送)
.GroupBy(u => u.Id) // - 全新任务(Id == null):全部保留,不参与去重
.Select(g => g.Last()) var dedupedById = request.Upserts
.Where(u => u.Id.HasValue)
.GroupBy(u => u.Id!.Value)
.Select(g => g.Last());
var newTasks = request.Upserts
.Where(u => !u.Id.HasValue);
var uniqueUpserts = dedupedById
.Concat(newTasks)
.Where(u => !string.IsNullOrWhiteSpace(u.Title)) .Where(u => !string.IsNullOrWhiteSpace(u.Title))
.OrderByDescending(u => u.LastModificationTime) // 较新的先处理
.ToList(); .ToList();
// 预先查询该用户所有已存在的任务 ID // 使用事务包裹,确保读取与写入的一致性
var existingIds = await _dbContext.Tasks // 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突
.Where(t => t.UserId == userId) // ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景;
.Select(t => t.Id) // 对于极端并发写入冲突,SaveChangesAsync 处有重试保护
.ToHashSetAsync(cancellationToken); const int maxSaveRetries = 3;
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射) for (int retry = 0; ; retry++)
var clientIdToEntity = new Dictionary<int, TaskEntity>();
// 第一遍:处理根任务(ParentTaskId == null
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
{ {
TaskEntity? entity = null; using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value)) try
{ {
// 更新已存在任务 // 在事务内查询已有任务,确保读取到最新已提交数据
entity = await _dbContext.Tasks var existingTasks = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken); .IgnoreQueryFilters()
if (entity != null) .Where(t => t.UserId == userId)
.ToDictionaryAsync(t => t.Id, cancellationToken);
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射)
var clientIdToEntity = new Dictionary<Guid, TaskEntity>();
// 第一遍:处理根任务(ParentTaskId == null
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
{ {
entity.Title = upsert.Title.Trim(); await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted;
entity.ParentTaskId = null;
entity.UpdatedAt = DateTime.UtcNow;
} }
// 保存根任务,获取服务器分配的 ID
await _dbContext.SaveChangesAsync(cancellationToken);
// 将新创建的根任务加入 existingTasks,供子任务反查
foreach (var kvp in clientIdToEntity)
{
existingTasks[kvp.Value.Id] = kvp.Value;
}
// 第二遍:处理子任务(ParentTaskId != null
foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue))
{
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
}
await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
break; // 成功,退出重试循环
} }
catch (DbUpdateException ex) when (retry < maxSaveRetries - 1
if (entity == null) && ex.InnerException is Microsoft.Data.Sqlite.SqliteException se
&& se.SqliteErrorCode == 19
&& se.Message.Contains("UNIQUE constraint"))
{ {
// 新建任务;EF Core 会自动分配新的服务器 ID // 并发写入冲突:其他事务已插入相同 Id 的任务
entity = new TaskEntity // 回滚当前事务,下次循环将重新查询并正确识别已存在实体
{ await transaction.RollbackAsync(cancellationToken);
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
ParentTaskId = null,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_dbContext.Tasks.Add(entity);
if (upsert.Id.HasValue) // 清理 ChangeTracker,避免下次循环携带已回滚的实体状态
foreach (var entry in _dbContext.ChangeTracker.Entries<TaskEntity>().ToList())
{ {
clientIdToEntity[upsert.Id.Value] = entity; entry.State = EntityState.Detached;
} }
continue; // 重试
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
} }
} }
// 保存根任务,获取服务器分配的 ID
await _dbContext.SaveChangesAsync(cancellationToken);
// 第二遍:处理子任务(ParentTaskId != null
foreach (var upsert in uniqueUpserts.Where(u => u.ParentTaskId.HasValue))
{
// 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID
int? parentId = upsert.ParentTaskId;
if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity))
{
parentId = parentEntity.Id;
}
TaskEntity? entity = null;
if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value))
{
// 更新已存在任务
entity = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (entity != null)
{
entity.Title = upsert.Title.Trim();
entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted;
entity.ParentTaskId = parentId;
entity.UpdatedAt = DateTime.UtcNow;
}
}
if (entity == null)
{
// 新建子任务
entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
ParentTaskId = parentId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_dbContext.Tasks.Add(entity);
if (upsert.Id.HasValue)
{
clientIdToEntity[upsert.Id.Value] = entity;
}
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
} }
return new SyncResponse return new SyncResponse
@@ -184,6 +168,163 @@ public class CloudTaskSyncService
}; };
} }
/// <summary>
/// 递归软删除任务及其子任务。
/// </summary>
private async Task SoftDeleteTaskRecursiveAsync(Guid userId, Guid taskId, CancellationToken cancellationToken)
{
var task = await _dbContext.Tasks
.IgnoreQueryFilters()
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == taskId, cancellationToken);
if (task != null && !task.IsDeleted)
{
task.IsDeleted = true;
task.DeletionTime = DateTime.UtcNow;
task.DeleterId = userId;
// 显式标记为已修改
_dbContext.Entry(task).State = EntityState.Modified;
// 递归删除子任务
var childTasks = await _dbContext.Tasks
.IgnoreQueryFilters()
.Where(t => t.ParentTaskId == taskId)
.ToListAsync(cancellationToken);
foreach (var child in childTasks)
{
await SoftDeleteTaskRecursiveAsync(userId, child.Id, cancellationToken);
}
}
}
/// <summary>
/// 处理单个 upsert 请求(创建或更新)。
/// </summary>
private async Task ProcessUpsertAsync(
Guid userId,
CloudTaskUpsert upsert,
Dictionary<Guid, TaskEntity> existingTasks,
Dictionary<Guid, TaskEntity> clientIdToEntity,
CancellationToken cancellationToken)
{
// 解析父任务 ID:若父任务是本批新创建的,重映射到服务器分配的 ID
Guid? parentId = upsert.ParentTaskId;
if (parentId.HasValue && clientIdToEntity.TryGetValue(parentId.Value, out var parentEntity))
{
parentId = parentEntity.Id;
}
TaskEntity? entity = null;
bool shouldAdd = false;
// 尝试从已缓存的字典中查找
TaskEntity? existingEntity = null;
bool foundInCache = upsert.Id.HasValue && existingTasks.TryGetValue(upsert.Id.Value, out existingEntity);
if (!foundInCache && upsert.Id.HasValue)
{
// 字典未命中,但客户端提供了 Id —— 可能是跨请求/跨 UserId 的遗留数据
// 直接查询 DB 以确保不会重复插入
existingEntity = await _dbContext.Tasks
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == upsert.Id.Value, cancellationToken);
}
if (existingEntity != null)
{
// 实体已存在(字典或 DB 中),进行 LWW 冲突判断
if (upsert.LastModificationTime.HasValue &&
(!existingEntity.LastModificationTime.HasValue ||
upsert.LastModificationTime >= existingEntity.LastModificationTime))
{
// 客户端版本更新,接受更新
entity = existingEntity;
entity.Title = (upsert.Title ?? string.Empty).Trim();
entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted;
entity.Code = upsert.Code ?? string.Empty;
entity.ParentTaskId = parentId;
entity.LastModificationTime = upsert.LastModificationTime;
entity.LastModifierId = userId;
// 若实体来自 AsNoTracking 查询(非 tracked),需显式附加并标记为 Modified
var entry = _dbContext.Entry(entity);
if (entry.State == EntityState.Detached)
{
_dbContext.Tasks.Attach(entity);
entry.State = EntityState.Modified;
}
else
{
entry.State = EntityState.Modified;
}
}
// 否则保持服务端版本不变,不做任何操作
}
else
{
// 确认不存在,新建任务
entity = new TaskEntity
{
Id = upsert.Id ?? Guid.NewGuid(),
UserId = userId,
Title = (upsert.Title ?? string.Empty).Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
Code = upsert.Code ?? string.Empty,
ParentTaskId = parentId,
CreationTime = DateTime.UtcNow,
CreatorId = userId,
LastModificationTime = upsert.LastModificationTime ?? DateTime.UtcNow,
LastModifierId = userId,
};
shouldAdd = true;
}
if (shouldAdd && entity != null)
{
// 防御性检查:确保实体 Id 未被 ChangeTracker 跟踪,防止 UNIQUE 约束冲突
var trackedEntry = _dbContext.ChangeTracker.Entries<TaskEntity>()
.FirstOrDefault(e => e.Entity.Id == entity.Id);
if (trackedEntry != null)
{
// 实体已被跟踪(可能来自并发操作或其他路径),使用已跟踪的实体
entity = trackedEntry.Entity;
if (trackedEntry.State != EntityState.Added)
{
// 仅更新非新增实体的字段
entity.Title = (upsert.Title ?? string.Empty).Trim();
entity.Priority = upsert.Priority;
entity.IsCompleted = upsert.IsCompleted;
entity.Code = upsert.Code ?? string.Empty;
entity.ParentTaskId = parentId;
entity.LastModificationTime = upsert.LastModificationTime ?? DateTime.UtcNow;
entity.LastModifierId = userId;
trackedEntry.State = EntityState.Modified;
}
}
else
{
_dbContext.Tasks.Add(entity);
}
// 立即更新 existingTasks,避免同一批次内重复插入相同 Id
existingTasks[entity.Id] = entity;
if (upsert.Id.HasValue)
{
clientIdToEntity[upsert.Id.Value] = entity;
}
}
}
/// <summary>
/// 将实体映射到 DTO。
/// </summary>
private static CloudTaskItem MapToItem(TaskEntity task) private static CloudTaskItem MapToItem(TaskEntity task)
{ {
return new CloudTaskItem return new CloudTaskItem
@@ -192,10 +333,15 @@ public class CloudTaskSyncService
Title = task.Title, Title = task.Title,
Priority = task.Priority, Priority = task.Priority,
IsCompleted = task.IsCompleted, IsCompleted = task.IsCompleted,
CreatedAtUtc = task.CreatedAt, Code = task.Code,
UpdatedAtUtc = task.UpdatedAt, ParentTaskId = task.ParentTaskId,
ParentTaskId = task.ParentTaskId CreationTime = task.CreationTime,
CreatorId = task.CreatorId,
LastModificationTime = task.LastModificationTime,
LastModifierId = task.LastModifierId,
IsDeleted = task.IsDeleted,
DeletionTime = task.DeletionTime,
DeleterId = task.DeleterId
}; };
} }
} }
+22 -5
View File
@@ -18,7 +18,7 @@ public class TodoDbContext : DbContext
} }
/// <summary> /// <summary>
/// 任务集合。 /// 任务集合ABP 表名规范:T_Tasks
/// </summary> /// </summary>
public DbSet<TaskEntity> Tasks { get; set; } public DbSet<TaskEntity> Tasks { get; set; }
@@ -52,17 +52,34 @@ public class TodoDbContext : DbContext
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter(); var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
// TaskEntity 配置(ABP 风格审计字段)
modelBuilder.Entity<TaskEntity>(entity => modelBuilder.Entity<TaskEntity>(entity =>
{ {
entity.ToTable("Tasks"); entity.ToTable("T_Tasks");
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
// 业务字段
entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId); entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium); entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
entity.Property(e => e.IsCompleted).HasDefaultValue(false); entity.Property(e => e.IsCompleted).HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')"); entity.Property(e => e.ParentTaskId).HasColumnType("TEXT");
entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
// ABP 审计字段(基类 FullAuditedEntityWithUser 提供)
entity.Property(e => e.ExtraProperties).HasColumnType("TEXT");
entity.Property(e => e.ConcurrencyStamp).HasColumnType("TEXT");
entity.Property(e => e.CreationTime).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.CreatorId).HasColumnType("TEXT");
entity.Property(e => e.LastModificationTime).HasConversion(utcDateTimeConverter);
entity.Property(e => e.LastModifierId).HasColumnType("TEXT");
entity.Property(e => e.IsDeleted).HasDefaultValue(false);
entity.Property(e => e.DeletionTime).HasConversion(utcDateTimeConverter);
entity.Property(e => e.DeleterId).HasColumnType("TEXT");
// 软删除查询过滤器
entity.HasQueryFilter(e => !e.IsDeleted);
// 导航属性
entity.HasOne(e => e.User) entity.HasOne(e => e.User)
.WithMany(u => u.Tasks) .WithMany(u => u.Tasks)
.HasForeignKey(e => e.UserId) .HasForeignKey(e => e.UserId)
@@ -127,4 +144,4 @@ public class TodoDbContext : DbContext
entity.HasIndex(e => e.TimestampUtc); entity.HasIndex(e => e.TimestampUtc);
}); });
} }
} }
@@ -3,66 +3,66 @@ using Hua.Todo.Application.Models;
namespace Hua.Todo.Application.Interfaces; namespace Hua.Todo.Application.Interfaces;
/// <summary> /// <summary>
/// 任务管理服务接口 /// 任务管理服务接口
/// </summary> /// </summary>
public interface ITaskService : IDynamicApiService public interface ITaskService : IDynamicApiService
{ {
/// <summary> /// <summary>
/// 获取所有任务 /// 获取所有任务
/// </summary> /// </summary>
/// <returns>包含所有任务的列表</returns> /// <returns>包含所有任务的列表</returns>
Task<List<TaskDto>> GetAllTasksAsync(); Task<List<TaskDto>> GetAllTasksAsync();
/// <summary> /// <summary>
/// 根据 ID 获取任务 /// 根据 ID 获取任务
/// </summary> /// </summary>
/// <param name="id">任务唯一标识符</param> /// <param name="id">任务 ID。</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns> /// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(int id); Task<TaskDto?> GetTaskByIdAsync(Guid id);
/// <summary> /// <summary>
/// 获取未完成的任务 /// 获取未完成的任务
/// </summary> /// </summary>
/// <returns>未完成任务的列表</returns> /// <returns>未完成任务的列表</returns>
Task<List<TaskDto>> GetActiveTasksAsync(); Task<List<TaskDto>> GetActiveTasksAsync();
/// <summary> /// <summary>
/// 获取已完成的任务 /// 获取已完成的任务
/// </summary> /// </summary>
/// <returns>已完成任务的列表</returns> /// <returns>已完成任务的列表</returns>
Task<List<TaskDto>> GetCompletedTasksAsync(); Task<List<TaskDto>> GetCompletedTasksAsync();
/// <summary> /// <summary>
/// 创建新任务 /// 创建新任务
/// </summary> /// </summary>
/// <param name="dto">创建任务所需的数据传输对象</param> /// <param name="dto">创建任务所需的数据传输对象</param>
/// <returns>创建成功的任务 DTO</returns> /// <returns>创建成功的任务 DTO</returns>
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto); Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
/// <summary> /// <summary>
/// 更新任务信息 /// 更新任务信息
/// </summary> /// </summary>
/// <param name="dto">包含更新信息的任务数据传输对象</param> /// <param name="dto">包含更新信息的任务数据传输对象</param>
/// <returns>更新后的任务 DTO</returns> /// <returns>更新后的任务 DTO</returns>
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto); Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
/// <summary> /// <summary>
/// 切换任务完成状态 /// 切换任务完成状态
/// </summary> /// </summary>
/// <param name="id">任务唯一标识符</param> /// <param name="id">任务 ID。</param>
/// <returns>更新状态后的任务 DTO</returns> /// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(int id); Task<TaskDto> ToggleCompleteAsync(Guid id);
/// <summary> /// <summary>
/// 删除任务 /// 删除任务
/// </summary> /// </summary>
/// <param name="id">要删除的任务唯一标识符</param> /// <param name="id">要删除的任务 ID。</param>
Task DeleteTaskAsync(int id); Task DeleteTaskAsync(Guid id);
/// <summary> /// <summary>
/// 获取子任务列表 /// 获取子任务列表
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务唯一标识符</param> /// <param name="parentTaskId">父任务 ID。</param>
/// <returns>该父任务下的所有子任务列表</returns> /// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId); Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId);
} }
@@ -0,0 +1,299 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("MakeTaskEntityAbpCompatible")]
partial class MakeTaskEntityAbpCompatible
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<string>("TimestampUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TimestampUtc");
b.HasIndex("UserId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowPersist")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AllowSync")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("IsTrustedDeviceOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("SecondFactorExpiryMinutes")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30);
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("SecurityPolicies", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreationTime")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid?>("CreatorId")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
.HasColumnType("TEXT");
b.Property<string>("ExtraProperties")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("TEXT");
b.Property<Guid?>("LastModifierId")
.HasColumnType("TEXT");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("TEXT");
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.HasIndex("UserId");
b.ToTable("T_Tasks", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("MustChangePassword")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ExpiresAtUtc")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StepUpExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany("Tasks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentTask");
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
{
b.Navigation("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
/// <summary>
/// 重构 TaskEntity 继承 ABP 风格审计基类。
/// - 主键从 int 改为 Guid
/// - ParentTaskId 从 int? 改为 Guid?
/// - 新增 ABP 审计字段:ExtraProperties, ConcurrencyStamp, CreationTime, CreatorId,
/// LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId
/// - 重命名 CreatedAt -> CreationTime, UpdatedAt -> LastModificationTime
/// - 表名从 Tasks 改为 T_Tasks
/// </summary>
public partial class MakeTaskEntityAbpCompatible : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. 创建临时表 T_Tasks(新结构)
migrationBuilder.CreateTable(
name: "T_Tasks",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ExtraProperties = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
CreationTime = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
CreatorId = table.Column<Guid>(type: "TEXT", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "TEXT", nullable: true),
LastModifierId = table.Column<Guid>(type: "TEXT", nullable: true),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
DeletionTime = table.Column<DateTime>(type: "TEXT", nullable: true),
DeleterId = table.Column<Guid>(type: "TEXT", nullable: true),
UserId = table.Column<Guid>(type: "TEXT", nullable: false, defaultValue: "local"),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
Code = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
ParentTaskId = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_T_Tasks", x => x.Id);
table.ForeignKey(
name: "FK_T_Tasks_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_T_Tasks_T_Tasks_ParentTaskId",
column: x => x.ParentTaskId,
principalTable: "T_Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
// 2. 创建索引
migrationBuilder.CreateIndex(
name: "IX_T_Tasks_ParentTaskId",
table: "T_Tasks",
column: "ParentTaskId");
migrationBuilder.CreateIndex(
name: "IX_T_Tasks_UserId",
table: "T_Tasks",
column: "UserId");
// 3. 数据迁移(int Id -> GuidCreatedAt -> CreationTimeUpdatedAt -> LastModificationTime
// 注意:原有数据会丢失,因为 int 无法直接转换为 Guid
// 如需保留数据,需先导出再导入
// 4. 先禁用外键检查,再重命名旧表(避免外键约束失败)
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;");
migrationBuilder.RenameTable(name: "Tasks", newName: "Tasks_Old");
migrationBuilder.Sql("PRAGMA foreign_keys = ON;");
// 注意:不重命名 T_Tasks 为 Tasks,因为 TodoDbContext 配置的表名是 T_Tasks
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// 此回滚为破坏性操作,数据会丢失
// 重建旧结构 Tasks 表(仅用于回滚,实际数据不可恢复)
// 1. 禁用外键检查
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;");
// 2. 删除新表
migrationBuilder.DropTable(name: "T_Tasks");
// 3. 重建旧结构 Tasks 表
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false, defaultValue: "local"),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
CreatedAt = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
UpdatedAt = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
ParentTaskId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
// 4. 如果有重命名的旧表,也删除
migrationBuilder.Sql("DROP TABLE IF EXISTS Tasks_Old;");
// 5. 重新启用外键检查
migrationBuilder.Sql("PRAGMA foreign_keys = ON;");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -102,23 +102,52 @@ namespace Hua.Todo.Application.Migrations
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b => modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{ {
b.Property<int>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("TEXT");
b.Property<string>("CreatedAt") b.Property<string>("ConcurrencyStamp")
.IsRequired() .HasColumnType("TEXT");
b.Property<DateTime>("CreationTime")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')"); .HasDefaultValueSql("datetime('now')");
b.Property<Guid?>("CreatorId")
.HasColumnType("TEXT");
b.Property<Guid?>("DeleterId")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("TEXT");
b.Property<string>("ExtraProperties")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted") b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<int?>("ParentTaskId") b.Property<string>("Code")
.HasColumnType("INTEGER"); .IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("TEXT");
b.Property<Guid?>("LastModifierId")
.HasColumnType("TEXT");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("TEXT");
b.Property<int>("Priority") b.Property<int>("Priority")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -130,12 +159,6 @@ namespace Hua.Todo.Application.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("UpdatedAt")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT") .HasColumnType("TEXT")
@@ -147,7 +170,7 @@ namespace Hua.Todo.Application.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("Tasks", (string)null); b.ToTable("T_Tasks", (string)null);
}); });
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b => modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
+24 -11
View File
@@ -13,16 +13,16 @@ public class CreateTaskDto
/// </summary> /// </summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary> /// <summary>
/// 任务优先级。 /// 任务优先级。
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium; public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary> /// <summary>
/// 父任务 ID(用于创建子任务)。 /// 父任务 ID(用于创建子任务)。
/// </summary> /// </summary>
public int? ParentTaskId { get; set; } public Guid? ParentTaskId { get; set; }
} }
/// <summary> /// <summary>
@@ -33,17 +33,17 @@ public class UpdateTaskDto
/// <summary> /// <summary>
/// 任务 ID。 /// 任务 ID。
/// </summary> /// </summary>
public int Id { get; set; } public Guid Id { get; set; }
/// <summary> /// <summary>
/// 新标题(可选)。 /// 新标题(可选)。
/// </summary> /// </summary>
public string? Title { get; set; } public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary> /// <summary>
/// 新优先级(可选)。 /// 新优先级(可选)。
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; } public TaskPriority? Priority { get; set; }
} }
@@ -55,34 +55,44 @@ public class TaskDto
/// <summary> /// <summary>
/// 任务 ID。 /// 任务 ID。
/// </summary> /// </summary>
public int Id { get; set; } public Guid Id { get; set; }
/// <summary> /// <summary>
/// 任务标题。 /// 任务标题。
/// </summary> /// </summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary> /// <summary>
/// 任务优先级。 /// 任务优先级。
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } public TaskPriority Priority { get; set; }
/// <summary> /// <summary>
/// 是否已完成。 /// 是否已完成。
/// </summary> /// </summary>
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }
/// <summary>
/// 任务编号(用户级自增字符串)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 创建时间(UTC)。 /// 创建时间(UTC)。
/// </summary> /// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreationTime { get; set; }
/// <summary> /// <summary>
/// 更新时间(UTC)。 /// 最后修改时间(UTC)。
/// </summary> /// </summary>
public DateTime UpdatedAt { get; set; } public DateTime? LastModificationTime { get; set; }
/// <summary> /// <summary>
/// 父任务 ID(可选)。 /// 父任务 ID(可选)。
/// </summary> /// </summary>
public int? ParentTaskId { get; set; } public Guid? ParentTaskId { get; set; }
/// <summary> /// <summary>
/// 子任务列表。 /// 子任务列表。
/// </summary> /// </summary>
@@ -99,16 +109,19 @@ public class ApiResponse<T>
/// 是否成功。 /// 是否成功。
/// </summary> /// </summary>
public bool Success { get; set; } public bool Success { get; set; }
/// <summary> /// <summary>
/// 返回数据(可选)。 /// 返回数据(可选)。
/// </summary> /// </summary>
public T? Data { get; set; } public T? Data { get; set; }
/// <summary> /// <summary>
/// 提示信息。 /// 提示信息。
/// </summary> /// </summary>
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 错误列表。 /// 错误列表。
/// </summary> /// </summary>
public List<string> Errors { get; set; } = new(); public List<string> Errors { get; set; } = new();
} }
@@ -22,14 +22,17 @@ public class TaskRepository : ITaskRepository
} }
/// <summary> /// <summary>
/// 获取所有任务。 /// 获取所有任务(本地用户),平面加载以支持任意深度树构建
/// 不再使用 Include(t => t.SubTasks) 因为 EF Core 无法递归加载未知深度的子任务;
/// 树的构建交由 <see cref="TaskService.GetAllTasksAsync"/> 通过 parentTaskId 在内存中完成。
/// </summary> /// </summary>
/// <returns>包含所有任务实体的列表。</returns> /// <returns>包含所有任务实体的平面列表。</returns>
public async Task<List<TaskEntity>> GetAllAsync() public async Task<List<TaskEntity>> GetAllAsync()
{ {
return await _context.Tasks return await _context.Tasks
.AsNoTracking()
.Where(t => t.UserId == TodoUserIds.LocalUserId) .Where(t => t.UserId == TodoUserIds.LocalUserId)
.Include(t => t.SubTasks) .OrderByDescending(t => t.CreationTime)
.ToListAsync(); .ToListAsync();
} }
@@ -38,7 +41,7 @@ public class TaskRepository : ITaskRepository
/// </summary> /// </summary>
/// <param name="id">任务 ID。</param> /// <param name="id">任务 ID。</param>
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns> /// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
public async Task<TaskEntity?> GetByIdAsync(int id) public async Task<TaskEntity?> GetByIdAsync(Guid id)
{ {
return await _context.Tasks return await _context.Tasks
.Include(t => t.SubTasks) .Include(t => t.SubTasks)
@@ -53,7 +56,7 @@ public class TaskRepository : ITaskRepository
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted) .Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreationTime)
.ToListAsync(); .ToListAsync();
} }
@@ -65,7 +68,7 @@ public class TaskRepository : ITaskRepository
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted) .Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt) .OrderByDescending(t => t.LastModificationTime)
.ToListAsync(); .ToListAsync();
} }
@@ -77,6 +80,12 @@ public class TaskRepository : ITaskRepository
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity) public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{ {
taskEntity.UserId = TodoUserIds.LocalUserId; taskEntity.UserId = TodoUserIds.LocalUserId;
if (taskEntity.Id == Guid.Empty)
{
taskEntity.Id = Guid.NewGuid();
}
taskEntity.CreationTime = DateTime.UtcNow;
taskEntity.CreatorId = taskEntity.UserId;
_context.Tasks.Add(taskEntity); _context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return taskEntity; return taskEntity;
@@ -89,18 +98,18 @@ public class TaskRepository : ITaskRepository
/// <returns>更新后的任务实体。</returns> /// <returns>更新后的任务实体。</returns>
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity) public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{ {
taskEntity.UpdatedAt = DateTime.UtcNow; taskEntity.LastModificationTime = DateTime.UtcNow;
taskEntity.LastModifierId = taskEntity.UserId;
_context.Tasks.Update(taskEntity); _context.Tasks.Update(taskEntity);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return taskEntity; return taskEntity;
} }
/// <summary> /// <summary>
/// 根据 ID 删除任务。 /// 根据 ID 删除任务(物理删除)
/// </summary> /// </summary>
/// <param name="id">要删除的任务 ID。</param> /// <param name="id">要删除的任务 ID。</param>
/// <returns>表示删除操作的任务。</returns> public async Task DeleteAsync(Guid id)
public async Task DeleteAsync(int id)
{ {
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId); var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
if (task != null) if (task != null)
@@ -115,11 +124,11 @@ public class TaskRepository : ITaskRepository
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务 ID。</param> /// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表。</returns> /// <returns>子任务实体的列表。</returns>
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId) public async Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId)
{ {
return await _context.Tasks return await _context.Tasks
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId) .Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreationTime)
.ToListAsync(); .ToListAsync();
} }
} }
@@ -6,46 +6,69 @@ using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Services; namespace Hua.Todo.Application.Services;
/// <summary> /// <summary>
/// 任务管理服务实现 /// 任务管理服务实现
/// </summary> /// </summary>
public class TaskService : ITaskService public class TaskService : ITaskService
{ {
private readonly ITaskRepository _taskRepository; private readonly ITaskRepository _taskRepository;
/// <summary> /// <summary>
/// 初始化任务管理服务的新实例 /// 初始化任务管理服务的新实例
/// </summary> /// </summary>
/// <param name="taskRepository">任务仓储接口</param> /// <param name="taskRepository">任务仓储接口</param>
public TaskService(ITaskRepository taskRepository) public TaskService(ITaskRepository taskRepository)
{ {
_taskRepository = taskRepository; _taskRepository = taskRepository;
} }
/// <summary> /// <summary>
/// 获取所有任务 /// 获取所有任务(包含任意深度的子树)。
/// 先从 DB 平面加载全部任务,再在内存中通过 parentTaskId 构建树结构。
/// </summary> /// </summary>
/// <returns>所有任务 DTO 列表</returns> /// <returns>完整的任务 DTO 列表</returns>
public async Task<List<TaskDto>> GetAllTasksAsync() public async Task<List<TaskDto>> GetAllTasksAsync()
{ {
var tasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList();
// 构建 DTO 字典(平面)
var dtoMap = new Dictionary<Guid, TaskDto>();
foreach (var task in allTasks)
{
dtoMap[task.Id] = MapToDto(task);
}
// 通过 parentTaskId 在内存中构建树结构(支持任意深度)
var roots = new List<TaskDto>();
foreach (var dto in dtoMap.Values)
{
if (dto.ParentTaskId.HasValue && dtoMap.TryGetValue(dto.ParentTaskId.Value, out var parent))
{
parent.SubTasks.Add(dto);
}
else
{
roots.Add(dto);
}
}
return roots;
} }
/// <summary> /// <summary>
/// 根据 ID 获取任务详情 /// 根据 ID 获取任务详情
/// </summary> /// </summary>
/// <param name="id">任务 ID</param> /// <param name="id">任务 ID</param>
/// <returns>找到的任务 DTO,如果不存在则返回 null</returns> /// <returns>找到的任务 DTO,如果不存在则返回 null</returns>
public async Task<TaskDto?> GetTaskByIdAsync(int id) public async Task<TaskDto?> GetTaskByIdAsync(Guid id)
{ {
var task = await _taskRepository.GetByIdAsync(id); var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null; return task != null ? MapToDto(task) : null;
} }
/// <summary> /// <summary>
/// 获取所有未完成的任务 /// 获取所有未完成的任务
/// </summary> /// </summary>
/// <returns>未完成任务的 DTO 列表</returns> /// <returns>未完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetActiveTasksAsync() public async Task<List<TaskDto>> GetActiveTasksAsync()
{ {
var allTasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
@@ -53,9 +76,9 @@ public class TaskService : ITaskService
} }
/// <summary> /// <summary>
/// 获取所有已完成的任务 /// 获取所有已完成的任务
/// </summary> /// </summary>
/// <returns>已完成任务的 DTO 列表</returns> /// <returns>已完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetCompletedTasksAsync() public async Task<List<TaskDto>> GetCompletedTasksAsync()
{ {
var allTasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
@@ -63,19 +86,24 @@ public class TaskService : ITaskService
} }
/// <summary> /// <summary>
/// 创建新任务 /// 创建新任务
/// </summary> /// </summary>
/// <param name="dto">任务创建 DTO</param> /// <param name="dto">任务创建 DTO</param>
/// <returns>新创建的任务 DTO</returns> /// <returns>新创建的任务 DTO</returns>
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{ {
var allExisting = await _taskRepository.GetAllAsync();
var maxCode = allExisting.Count > 0
? allExisting.Max(t => int.TryParse(t.Code, out var c) ? c : 0)
: 0;
var task = new TaskEntity var task = new TaskEntity
{ {
Id = Guid.NewGuid(),
Title = dto.Title, Title = dto.Title,
Priority = dto.Priority, Priority = dto.Priority,
IsCompleted = false, IsCompleted = false,
CreatedAt = DateTime.UtcNow, Code = (maxCode + 1).ToString(),
UpdatedAt = DateTime.UtcNow,
ParentTaskId = dto.ParentTaskId ParentTaskId = dto.ParentTaskId
}; };
@@ -84,11 +112,11 @@ public class TaskService : ITaskService
} }
/// <summary> /// <summary>
/// 更新现有任务 /// 更新现有任务
/// </summary> /// </summary>
/// <param name="dto">包含更新内容的 DTO</param> /// <param name="dto">包含更新内容的 DTO</param>
/// <returns>更新后的任务 DTO</returns> /// <returns>更新后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception> /// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto) public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{ {
var task = await _taskRepository.GetByIdAsync(dto.Id); var task = await _taskRepository.GetByIdAsync(dto.Id);
@@ -107,19 +135,17 @@ public class TaskService : ITaskService
task.Priority = dto.Priority.Value; task.Priority = dto.Priority.Value;
} }
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task); var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask); return MapToDto(updatedTask);
} }
/// <summary> /// <summary>
/// 切换任务的完成状态 /// 切换任务的完成状态
/// </summary> /// </summary>
/// <param name="id">任务 ID</param> /// <param name="id">任务 ID</param>
/// <returns>状态切换后的任务 DTO</returns> /// <returns>状态切换后的任务 DTO</returns>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception> /// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task<TaskDto> ToggleCompleteAsync(int id) public async Task<TaskDto> ToggleCompleteAsync(Guid id)
{ {
var task = await _taskRepository.GetByIdAsync(id); var task = await _taskRepository.GetByIdAsync(id);
if (task == null) if (task == null)
@@ -128,18 +154,17 @@ public class TaskService : ITaskService
} }
task.IsCompleted = !task.IsCompleted; task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task); var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask); return MapToDto(updatedTask);
} }
/// <summary> /// <summary>
/// 删除指定 ID 的任务 /// 删除指定 ID 的任务
/// </summary> /// </summary>
/// <param name="id">任务 ID</param> /// <param name="id">任务 ID</param>
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception> /// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
public async Task DeleteTaskAsync(int id) public async Task DeleteTaskAsync(Guid id)
{ {
var task = await _taskRepository.GetByIdAsync(id); var task = await _taskRepository.GetByIdAsync(id);
if (task == null) if (task == null)
@@ -151,20 +176,21 @@ public class TaskService : ITaskService
} }
/// <summary> /// <summary>
/// 获取指定父任务的所有子任务 /// 获取指定父任务的所有子任务
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务 ID</param> /// <param name="parentTaskId">父任务 ID</param>
/// <returns>子任务的 DTO 列表</returns> /// <returns>子任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId) public async Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
{ {
var allTasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList(); return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
} }
/// <summary> /// <summary>
/// 实体转 DTO 映射方法 /// 实体转 DTO 映射方法
/// 注意:SubTasks 默认为空列表,由 <see cref="GetAllTasksAsync"/> 在内存中通过 parentTaskId 构建树。
/// </summary> /// </summary>
private TaskDto MapToDto(TaskEntity task) private static TaskDto MapToDto(TaskEntity task)
{ {
return new TaskDto return new TaskDto
{ {
@@ -172,10 +198,11 @@ public class TaskService : ITaskService
Title = task.Title, Title = task.Title,
Priority = task.Priority, Priority = task.Priority,
IsCompleted = task.IsCompleted, IsCompleted = task.IsCompleted,
CreatedAt = task.CreatedAt, Code = task.Code,
UpdatedAt = task.UpdatedAt, CreationTime = task.CreationTime,
LastModificationTime = task.LastModificationTime,
ParentTaskId = task.ParentTaskId, ParentTaskId = task.ParentTaskId,
SubTasks = task.SubTasks.Select(MapToDto).ToList() SubTasks = new List<TaskDto>()
}; };
} }
} }
@@ -27,6 +27,13 @@ public class WebServerSettings
[JsonPropertyName("ForEndUrl")] [JsonPropertyName("ForEndUrl")]
public string ForEndUrl { get; set; } = "http://localhost:5174"; public string ForEndUrl { get; set; } = "http://localhost:5174";
/// <summary>
/// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。
/// 空字符串表示未配置云同步。
/// </summary>
[JsonPropertyName("CloudSyncUrl")]
public string CloudSyncUrl { get; set; } = "";
} }
public class HotKeyDefaultSettings public class HotKeyDefaultSettings
@@ -11,6 +11,7 @@ using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Hua.Todo.Application; using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Data;
using Hua.Todo.Application.DynamicApi; using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Avalonia.Models; using Hua.Todo.Avalonia.Models;
@@ -68,6 +69,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
// 注册应用逻辑服务 // 注册应用逻辑服务
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
// 注册云同步反向代理(URL 管理 + 转发用 HttpClient
builder.Services.AddCloudSyncProxy();
// 注册 URL 持久化
builder.Services.AddSingleton(new CloudSyncProxySettingsPersistence(
Path.Combine(AppContext.BaseDirectory, "appsettings.json")));
// 配置跨域策略 // 配置跨域策略
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@@ -81,6 +89,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
var app = builder.Build(); var app = builder.Build();
// 从 appsettings 初始化 CloudSyncUrl
var proxySettings = app.Services.GetRequiredService<CloudSyncProxySettings>();
if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl))
{
proxySettings.SetUrl(_appSettings.WebServer.CloudSyncUrl);
}
InitializeDatabase(app); InitializeDatabase(app);
// 如果配置为使用静态文件(前端托管),则配置静态文件服务 // 如果配置为使用静态文件(前端托管),则配置静态文件服务
@@ -90,9 +105,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
} }
app.UseCors("AllowAll"); app.UseCors("AllowAll");
app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权)
app.UseAuthorization(); app.UseAuthorization();
app.UseDynamicApi(); app.UseDynamicApi();
app.MapControllers(); app.MapControllers();
app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings
_webApp = app; _webApp = app;
+2 -1
View File
@@ -4,7 +4,8 @@
"IsUsingStatic": true, "IsUsingStatic": true,
"ConnectionString": "", "ConnectionString": "",
"HostUrl": "http://localhost:5057", "HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5057" "ForEndUrl": "http://localhost:5057",
"CloudSyncUrl": ""
}, },
"HotKey": { "HotKey": {
"DefaultModifiers": "Alt", "DefaultModifiers": "Alt",
@@ -0,0 +1,69 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 完整审计实体基类(ABP 风格),包含创建、修改、删除审计字段。
/// </summary>
/// <typeparam name="TKey">主键类型。</typeparam>
/// <typeparam name="TUser">用户实体类型。</typeparam>
public abstract class FullAuditedEntityWithUser<TKey, TUser> where TKey : struct
{
/// <summary>
/// 主键。
/// </summary>
public TKey Id { get; set; } = default!;
/// <summary>
/// 扩展属性字典(ABP 框架扩展属性)。
/// 基类已处理序列化,实体中不直接生成属性。
/// </summary>
public string? ExtraProperties { get; set; }
/// <summary>
/// 并发戳,用于乐观并发控制。
/// </summary>
public string? ConcurrencyStamp { get; set; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreationTime { get; set; }
/// <summary>
/// 创建人 ID。
/// </summary>
public TKey? CreatorId { get; set; }
/// <summary>
/// 最后修改时间。
/// </summary>
public DateTime? LastModificationTime { get; set; }
/// <summary>
/// 最后修改人 ID。
/// </summary>
public TKey? LastModifierId { get; set; }
/// <summary>
/// 软删除标记。
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// 删除时间。
/// </summary>
public DateTime? DeletionTime { get; set; }
/// <summary>
/// 删除人 ID。
/// </summary>
public TKey? DeleterId { get; set; }
}
/// <summary>
/// 用户实体接口(用于导航属性类型约束)。
/// </summary>
public interface IUser<TKey> where TKey : struct
{
TKey Id { get; set; }
string UserName { get; set; }
}
+23 -32
View File
@@ -1,13 +1,13 @@
namespace Hua.Todo.Core.Entities; namespace Hua.Todo.Core.Entities;
/// <summary> /// <summary>
/// 任务实体类,表示一个代办 /// 任务实体类,表示一个 Todo 待办项。
/// 继承 <see cref="FullAuditedEntityWithUser{Guid, UserEntity}"/> 以获得 ABP 风格的审计字段。
/// </summary> /// </summary>
public class TaskEntity public class TaskEntity : FullAuditedEntityWithUser<Guid, UserEntity>
{ {
/// <summary> /// <summary>
/// 任务所属用户 ID。 /// 任务所属用户 ID(云端隔离),本地模式为 <see cref="TodoUserIds.LocalUserId"/>
/// 本地模式下使用固定的“本地用户”ID,以确保本地任务与云端多用户任务隔离。
/// </summary> /// </summary>
public Guid UserId { get; set; } = TodoUserIds.LocalUserId; public Guid UserId { get; set; } = TodoUserIds.LocalUserId;
@@ -17,47 +17,38 @@ public class TaskEntity
public UserEntity? User { get; set; } public UserEntity? User { get; set; }
/// <summary> /// <summary>
/// 任务唯一标识符 /// 任务标题。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 任务标题
/// </summary> /// </summary>
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
/// <summary> /// <summary>
/// 任务优先级 /// 任务优先级
/// </summary> /// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium; public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary> /// <summary>
/// 任务是否已完成 /// 任务编号(用户级自增字符串,用于展示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 任务是否已完成。
/// </summary> /// </summary>
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }
/// <summary> /// <summary>
/// 任务创建时间(UTC /// 任务 ID(外键),用于支持子任务功能。
/// 类型为 <see cref="Guid"/>,与主键类型一致。
/// </summary> /// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public Guid? ParentTaskId { get; set; }
/// <summary> /// <summary>
/// 任务最后更新时间(UTC /// 任务导航属性。
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 父任务ID,用于支持子任务功能
/// </summary>
public int? ParentTaskId { get; set; }
/// <summary>
/// 父任务导航属性
/// </summary> /// </summary>
public TaskEntity? ParentTask { get; set; } public TaskEntity? ParentTask { get; set; }
/// <summary> /// <summary>
/// 子任务集合 /// 子任务集合
/// </summary> /// </summary>
public List<TaskEntity> SubTasks { get; set; } = new(); public List<TaskEntity> SubTasks { get; set; } = new();
} }
+1 -1
View File
@@ -3,7 +3,7 @@ namespace Hua.Todo.Core.Entities;
/// <summary> /// <summary>
/// 用户实体,用于云同步场景下的用户隔离与权限控制。 /// 用户实体,用于云同步场景下的用户隔离与权限控制。
/// </summary> /// </summary>
public class UserEntity public class UserEntity : IUser<Guid>
{ {
/// <summary> /// <summary>
/// 用户唯一标识符。 /// 用户唯一标识符。
+32 -33
View File
@@ -3,60 +3,59 @@ using Hua.Todo.Core.Entities;
namespace Hua.Todo.Core.Interfaces; namespace Hua.Todo.Core.Interfaces;
/// <summary> /// <summary>
/// 任务仓储接口,定义任务数据访问操作 /// 任务仓储接口,定义任务数据访问操作
/// </summary> /// </summary>
public interface ITaskRepository public interface ITaskRepository
{ {
/// <summary> /// <summary>
/// 获取所有任务 /// 获取所有任务
/// </summary> /// </summary>
/// <returns>包含所有任务实体的列表任务</returns> /// <returns>包含所有任务实体的列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetAllAsync(); System.Threading.Tasks.Task<List<TaskEntity>> GetAllAsync();
/// <summary> /// <summary>
/// 根据ID获取指定任务 /// 根据 ID 获取任务
/// </summary> /// </summary>
/// <param name="id">任务唯一标识符</param> /// <param name="id">任务 ID。</param>
/// <returns>任务实体对象,如果不存在则返回 null 的任务</returns> /// <returns>匹配的任务实体如果不存在则返回 null</returns>
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(int id); System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(Guid id);
/// <summary> /// <summary>
/// 获取所有未完成任务 /// 获取未完成任务列表。
/// </summary> /// </summary>
/// <returns>未完成任务实体列表任务</returns> /// <returns>未完成任务实体列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetActiveTasksAsync(); System.Threading.Tasks.Task<List<TaskEntity>> GetActiveTasksAsync();
/// <summary> /// <summary>
/// 获取所有已完成任务 /// 获取已完成任务列表。
/// </summary> /// </summary>
/// <returns>已完成任务实体列表任务</returns> /// <returns>已完成任务实体列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetCompletedTasksAsync(); System.Threading.Tasks.Task<List<TaskEntity>> GetCompletedTasksAsync();
/// <summary> /// <summary>
/// 添加新任务 /// 新增一个任务
/// </summary> /// </summary>
/// <param name="taskEntity">要添加的任务实体对象</param> /// <param name="taskEntity">要添加的任务实体</param>
/// <returns>添加后的任务实体(包含生成的 ID的任务</returns> /// <returns>已持久化的任务实体(包含生成的 ID</returns>
System.Threading.Tasks.Task<TaskEntity> AddAsync(TaskEntity taskEntity); System.Threading.Tasks.Task<TaskEntity> AddAsync(TaskEntity taskEntity);
/// <summary> /// <summary>
/// 更新任务 /// 更新现有任务信息。
/// </summary> /// </summary>
/// <param name="taskEntity">要更新的任务实体对象</param> /// <param name="taskEntity">要更新的任务实体</param>
/// <returns>更新后的任务实体对象的任务</returns> /// <returns>更新后的任务实体</returns>
System.Threading.Tasks.Task<TaskEntity> UpdateAsync(TaskEntity taskEntity); System.Threading.Tasks.Task<TaskEntity> UpdateAsync(TaskEntity taskEntity);
/// <summary> /// <summary>
/// 删除指定ID的任务 /// 根据 ID 删除任务(物理删除)。
/// </summary> /// </summary>
/// <param name="id">要删除的任务唯一标识符</param> /// <param name="id">要删除的任务 ID。</param>
/// <returns>表示删除操作的任务</returns> System.Threading.Tasks.Task DeleteAsync(Guid id);
System.Threading.Tasks.Task DeleteAsync(int id);
/// <summary> /// <summary>
/// 获取指定父任务的所有子任务 /// 获取指定父任务的子任务列表。
/// </summary> /// </summary>
/// <param name="parentTaskId">父任务唯一标识符</param> /// <param name="parentTaskId">父任务 ID。</param>
/// <returns>子任务实体的列表任务</returns> /// <returns>子任务实体的列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId); System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId);
} }
+1
View File
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Hua.Todo.Application; using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.Data; using Hua.Todo.Application.Data;
using Hua.Todo.Maui.Models; using Hua.Todo.Maui.Models;
using Hua.Todo.Maui.Services; using Hua.Todo.Maui.Services;
+7
View File
@@ -29,6 +29,13 @@ public class WebServerSettings
[JsonPropertyName("ForEndUrl")] [JsonPropertyName("ForEndUrl")]
public string ForEndUrl { get; set; } = "http://localhost:5174"; public string ForEndUrl { get; set; } = "http://localhost:5174";
/// <summary>
/// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。
/// 空字符串表示未配置云同步。
/// </summary>
[JsonPropertyName("CloudSyncUrl")]
public string CloudSyncUrl { get; set; } = "";
} }
public class HotKeyDefaultSettings public class HotKeyDefaultSettings
@@ -7,6 +7,7 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using System.Text.Json; using System.Text.Json;
using Hua.Todo.Application; using Hua.Todo.Application;
using Hua.Todo.Application.CloudSync;
using Hua.Todo.Application.DynamicApi; using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.DynamicApi.Swagger; using Hua.Todo.Application.DynamicApi.Swagger;
using Hua.Todo.Maui.Models; using Hua.Todo.Maui.Models;
@@ -76,6 +77,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
// 注册应用逻辑服务 // 注册应用逻辑服务
builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString);
// 注册云同步反向代理(URL 管理 + 转发用 HttpClient
builder.Services.AddCloudSyncProxy();
// 注册 URL 持久化
builder.Services.AddSingleton(new CloudSyncProxySettingsPersistence(
Path.Combine(AppContext.BaseDirectory, "appsettings.json")));
// 配置跨域策略 // 配置跨域策略
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@@ -89,6 +97,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
var app = builder.Build(); var app = builder.Build();
// 从 appsettings 初始化 CloudSyncUrl
var proxySettings = app.Services.GetRequiredService<CloudSyncProxySettings>();
if (!string.IsNullOrEmpty(_appSettings.WebServer.CloudSyncUrl))
{
proxySettings.SetUrl(_appSettings.WebServer.CloudSyncUrl);
}
#if DEBUG #if DEBUG
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
@@ -101,9 +116,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
} }
app.UseCors("AllowAll"); app.UseCors("AllowAll");
app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权)
app.UseAuthorization(); app.UseAuthorization();
app.UseDynamicApi(); app.UseDynamicApi();
app.MapControllers(); app.MapControllers();
app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings
_webApp = app; _webApp = app;
+2 -1
View File
@@ -4,7 +4,8 @@
"IsUsingStatic": false, "IsUsingStatic": false,
"ConnectionString": "", "ConnectionString": "",
"HostUrl": "http://localhost:5057", "HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174" "ForEndUrl": "http://localhost:5174",
"CloudSyncUrl": ""
}, },
"Development": { "Development": {
}, },
+137
View File
@@ -0,0 +1,137 @@
using System.Text.Json;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Tests;
/// <summary>
/// CloudSync DTO 单元测试。
/// </summary>
public class CloudSyncDtoTests
{
/// <summary>
/// 测试 CloudTaskItem JSON 序列化使用 camelCase。
/// </summary>
[Fact]
public void CloudTaskItem_Serialization_UsesCamelCase()
{
// Arrange
var item = new CloudTaskItem
{
Id = Guid.Parse("12345678-1234-1234-1234-123456789012"),
Title = "Test",
Priority = TaskPriority.High,
IsCompleted = true,
CreationTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
LastModificationTime = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc),
IsDeleted = false
};
// Act
var json = JsonSerializer.Serialize(item);
var deserialized = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
// Assert
Assert.NotNull(deserialized);
Assert.True(deserialized.ContainsKey("id"));
Assert.True(deserialized.ContainsKey("title"));
Assert.True(deserialized.ContainsKey("priority"));
Assert.True(deserialized.ContainsKey("isCompleted"));
Assert.True(deserialized.ContainsKey("creationTime"));
Assert.True(deserialized.ContainsKey("lastModificationTime"));
Assert.True(deserialized.ContainsKey("isDeleted"));
}
/// <summary>
/// 测试 CloudTaskUpsert JSON 反序列化。
/// </summary>
[Fact]
public void CloudTaskUpsert_Deserialization_FromCamelCase()
{
// Arrange
var json = @"{""id"":""12345678-1234-1234-1234-123456789012"",""title"":""Test Task"",""priority"":2,""isCompleted"":true,""lastModificationTime"":""2024-01-02T00:00:00Z""}";
// Act
var upsert = JsonSerializer.Deserialize<CloudTaskUpsert>(json);
// Assert
Assert.NotNull(upsert);
Assert.Equal(Guid.Parse("12345678-1234-1234-1234-123456789012"), upsert.Id);
Assert.Equal("Test Task", upsert.Title);
Assert.Equal(TaskPriority.High, upsert.Priority);
Assert.True(upsert.IsCompleted);
}
/// <summary>
/// 测试 SyncRequest JSON 反序列化。
/// </summary>
[Fact]
public void SyncRequest_Deserialization()
{
// Arrange
var json = @"{
""upserts"": [
{""id"":""12345678-1234-1234-1234-123456789012"",""title"":""Task 1""},
{""title"":""Task 2""}
],
""deletes"": [""87654321-4321-4321-4321-210987654321""]
}";
// Act
var request = JsonSerializer.Deserialize<SyncRequest>(json);
// Assert
Assert.NotNull(request);
Assert.Equal(2, request.Upserts.Count);
Assert.Single(request.Deletes);
}
/// <summary>
/// 测试 SyncResponse JSON 序列化。
/// </summary>
[Fact]
public void SyncResponse_Serialization()
{
// Arrange
var response = new SyncResponse
{
ServerTimeUtc = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
Tasks = new List<CloudTaskItem>
{
new CloudTaskItem
{
Id = Guid.NewGuid(),
Title = "Task 1",
Priority = TaskPriority.Medium
}
}
};
// Act
var json = JsonSerializer.Serialize(response);
var deserialized = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
// Assert
Assert.NotNull(deserialized);
Assert.True(deserialized.ContainsKey("serverTimeUtc"));
Assert.True(deserialized.ContainsKey("tasks"));
}
/// <summary>
/// 测试 Guid 类型的 JSON 序列化。
/// </summary>
[Fact]
public void Guid_Serialization_IsString()
{
// Arrange
var guid = Guid.NewGuid();
var item = new CloudTaskItem { Id = guid };
// Act
var json = JsonSerializer.Serialize(item);
// Assert
Assert.Contains(guid.ToString(), json);
}
}
@@ -0,0 +1,420 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Tests;
/// <summary>
/// CloudTaskSyncService SQLite 集成测试(验证 UNIQUE 约束不会在生产环境中触发)。
/// </summary>
public class CloudTaskSyncServiceSqliteTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TodoDbContext _dbContext;
private readonly CloudTaskSyncService _service;
private readonly Guid _testUserId;
public CloudTaskSyncServiceSqliteTests()
{
_testUserId = Guid.NewGuid();
// 使用共享缓存的 SQLite 内存数据库
_connection = new SqliteConnection("Data Source=CloudTaskSyncTests;Mode=Memory;Cache=Shared");
_connection.Open();
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
_dbContext = new TodoDbContext(options);
_dbContext.Database.EnsureCreated();
// 创建测试用户(满足 UserId 外键约束)
_dbContext.Users.Add(new UserEntity
{
Id = _testUserId,
UserName = "test_user",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
_dbContext.SaveChanges();
_service = new CloudTaskSyncService(_dbContext);
}
public void Dispose()
{
_dbContext.Dispose();
_connection.Dispose();
}
/// <summary>
/// 复现用户报告的 Bug:同一批次中同步父子任务(均有已知 IdlastModificationTime 为 null)。
/// 期望:首次同步成功创建两个任务。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_FirstSync_Succeeds()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId,
Title = "子任务",
Priority = TaskPriority.Medium,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId,
Title = "父任务",
Priority = TaskPriority.High,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
var parent = response.Tasks.First(t => t.Title == "父任务");
Assert.Null(parent.ParentTaskId);
var child = response.Tasks.First(t => t.Title == "子任务");
Assert.Equal(parentId, child.ParentTaskId);
}
/// <summary>
/// 复现 Bug 场景:首次同步成功后再用相同数据重新同步(模拟客户端重试)。
/// lastModificationTime 为 null 时 LWW 应跳过更新,不应抛出 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_Resync_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
// 首次同步
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, firstResponse.Tasks.Count);
// 重新同步(同一 DbContext,模拟客户端重试)
var resyncResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, resyncResponse.Tasks.Count);
}
/// <summary>
/// 测试多次重试同步不引发 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_MultipleResyncs_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
for (int i = 0; i < 5; i++)
{
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
}
}
/// <summary>
/// 测试已有 DbContext 跟踪实体的场景下同步不会引发 UNIQUE 冲突。
/// 模拟场景:任务已通过其他 API 创建并仍被跟踪,然后通过 CloudSync 同步同一数据。
/// </summary>
[Fact]
public async Task SyncAsync_TaskExistsThenSynced_NoUniqueViolation()
{
var taskId = Guid.NewGuid();
var existingTask = new TaskEntity
{
Id = taskId,
UserId = _testUserId,
Title = "已有任务",
Priority = TaskPriority.Medium,
CreationTime = DateTime.UtcNow,
CreatorId = _testUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = _testUserId
};
_dbContext.Tasks.Add(existingTask);
await _dbContext.SaveChangesAsync();
var syncRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "已有任务",
LastModificationTime = null
}
}
};
var response = await _service.SyncAsync(_testUserId, syncRequest, CancellationToken.None);
Assert.Single(response.Tasks);
Assert.Equal("已有任务", response.Tasks.First().Title);
}
/// <summary>
/// 复现用户 Bug 的核心场景:使用独立 DbContext 模拟跨请求重试。
/// Context A 创建任务并提交,Context B 再次同步相同数据时不应触发 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_CrossContextResync_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
// Context A:首次同步
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, firstResponse.Tasks.Count);
// Context B:使用全新 DbContext 重新同步(模拟另一个请求)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB);
var resyncResponse = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, resyncResponse.Tasks.Count);
}
/// <summary>
/// 使用报错场景中的精确 UUID 复现 Bug:
/// 父子任务在同一批次中同步,lastModificationTime 均为 null。
/// </summary>
[Fact]
public async Task SyncAsync_ExactBugReportUuids_NoUniqueViolation()
{
// 使用报错日志中的精确 UUID
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
// 首次同步
var response1 = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response1.Tasks.Count);
// 使用全新 DbContext 重新同步(模拟跨请求重试)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB);
// 重试同步 - 不应抛出 UNIQUE 约束
var response2 = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response2.Tasks.Count);
}
/// <summary>
/// 测试同一次同步请求中,父任务已存在于 DB 但 lastModificationTime 为 null 的重同步场景。
/// </summary>
[Fact]
public async Task SyncAsync_ExistingTaskWithNullTime_NoUniqueViolation()
{
// 使用精确 UUID
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
// 先创建一个已存在的父任务(模拟前置同步已完成)
var existingParent = new TaskEntity
{
Id = parentId,
UserId = _testUserId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
CreationTime = DateTime.UtcNow,
CreatorId = _testUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = _testUserId
};
_dbContext.Tasks.Add(existingParent);
await _dbContext.SaveChangesAsync();
// 同步请求(lastModificationTime 为 null,应被 LWW 跳过)
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null // 🔑 关键:null 时间戳
}
}
};
// 不应抛出 UNIQUE 约束
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Single(response.Tasks);
}
/// <summary>
/// 验证跨 UserId 同步相同 Id 不再触发 UNIQUE 约束。
/// 任务已存在于 DB(通过其他 UserId 创建),用不同 UserId 同步相同 Id,
/// 应被 ProcessUpsertAsync 的 DB 级别二次查重发现并安全跳过。
/// </summary>
[Fact]
public async Task SyncAsync_TaskExistsWithDifferentUserId_HandlesGracefully()
{
// 创建另一个用户的 DbContext
var otherUserId = Guid.NewGuid();
_dbContext.Users.Add(new UserEntity
{
Id = otherUserId,
UserName = "other_user",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
await _dbContext.SaveChangesAsync();
// 使用报错 UUID 创建任务,但属于 otherUserId
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
_dbContext.Tasks.Add(new TaskEntity
{
Id = parentId,
UserId = otherUserId,
Title = "other user task",
Priority = (TaskPriority)1,
CreationTime = DateTime.UtcNow,
CreatorId = otherUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = otherUserId
});
await _dbContext.SaveChangesAsync();
// 使用独立 DbContext 同步相同 Id(模拟跨请求)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB);
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
// 应正常完成(不抛出 DbUpdateException),DB 级别二次查重发现已存在实体并跳过
// 注意:response.Tasks 为空,因为该任务属于 otherUserId,不属于 _testUserId
var response = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Empty(response.Tasks);
}
}
@@ -0,0 +1,678 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Tests;
/// <summary>
/// CloudTaskSyncService 单元测试。
/// </summary>
public class CloudTaskSyncServiceTests : IDisposable
{
private readonly TodoDbContext _dbContext;
private readonly CloudTaskSyncService _service;
private readonly Guid _testUserId = Guid.NewGuid();
public CloudTaskSyncServiceTests()
{
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
_dbContext = new TodoDbContext(options);
_service = new CloudTaskSyncService(_dbContext);
}
public void Dispose()
{
_dbContext.Dispose();
}
/// <summary>
/// 测试创建新任务。
/// </summary>
[Fact]
public async Task SyncAsync_NewTask_CreatesTask()
{
// Arrange
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = "Test Task",
Priority = TaskPriority.High,
IsCompleted = false,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
var task = response.Tasks.First();
Assert.Equal("Test Task", task.Title);
Assert.Equal(TaskPriority.High, task.Priority);
Assert.False(task.IsCompleted);
Assert.NotEqual(Guid.Empty, task.Id);
}
/// <summary>
/// 测试更新已有任务。
/// </summary>
[Fact]
public async Task SyncAsync_ExistingTask_UpdatesTask()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated Task",
Priority = TaskPriority.Low,
IsCompleted = true,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
await _service.SyncAsync(_testUserId, request, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response);
var task = response.First();
Assert.Equal(taskId, task.Id);
Assert.Equal("Updated Task", task.Title);
Assert.Equal(TaskPriority.Low, task.Priority);
Assert.True(task.IsCompleted);
}
/// <summary>
/// 测试删除任务(Tombstone)。
/// </summary>
[Fact]
public async Task SyncAsync_DeleteTask_MarksAsDeleted()
{
// Arrange
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Task to Delete",
LastModificationTime = DateTime.UtcNow
}
}
};
var createResponse = await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
Assert.Single(createResponse.Tasks);
var deleteRequest = new SyncRequest
{
Deletes = new List<Guid> { taskId }
};
// Act
var deleteResponse = await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None);
// Assert - 直接检查返回的任务
var deletedTask = deleteResponse.Tasks.FirstOrDefault(t => t.Id == taskId);
Assert.NotNull(deletedTask);
Assert.True(deletedTask.IsDeleted);
Assert.NotNull(deletedTask.DeletionTime);
}
/// <summary>
/// 测试 LWW 冲突解决:客户端更新时接受较新版本。
/// </summary>
[Fact]
public async Task SyncAsync_LWW_ClientNewer_AcceptsClientVersion()
{
// Arrange
var taskId = Guid.NewGuid();
var olderTime = DateTime.UtcNow.AddMinutes(-10);
var newerTime = DateTime.UtcNow;
// 先创建旧版本
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Original",
LastModificationTime = olderTime
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
// 再提交新版本
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated by Client",
LastModificationTime = newerTime
}
}
};
// Act
await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response);
var task = response.First();
Assert.Equal("Updated by Client", task.Title);
}
/// <summary>
/// 测试 LWW 冲突解决:服务端更新时拒绝旧版本。
/// </summary>
[Fact]
public async Task SyncAsync_LWW_ServerNewer_KeepsServerVersion()
{
// Arrange
var taskId = Guid.NewGuid();
var serverTime = DateTime.UtcNow;
var clientOldTime = DateTime.UtcNow.AddMinutes(-10);
// 先创建服务端版本
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Server Version",
LastModificationTime = serverTime
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
// 再提交客户端旧版本
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Client Old Version",
LastModificationTime = clientOldTime
}
}
};
// Act
await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response);
var task = response.First();
Assert.Equal("Server Version", task.Title);
}
/// <summary>
/// 测试获取任务全量(含 Tombstone)。
/// </summary>
[Fact]
public async Task GetTasksAsync_ReturnsAllTasksIncludingDeleted()
{
// Arrange
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Task",
LastModificationTime = DateTime.UtcNow
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
var deleteRequest = new SyncRequest
{
Deletes = new List<Guid> { taskId }
};
await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None);
// Act
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response); // GetTasksAsync returns all including deleted
Assert.True(response.First().IsDeleted);
}
/// <summary>
/// 测试父子任务关系。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildTask_CreatesRelationship()
{
// Arrange
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent Task",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child Task",
ParentTaskId = parentId,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
await _service.SyncAsync(_testUserId, request, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Equal(2, response.Count);
var child = response.First(t => t.Title == "Child Task");
Assert.Equal(parentId, child.ParentTaskId);
}
/// <summary>
/// 测试软删除时递归删除子任务。
/// </summary>
[Fact]
public async Task SyncAsync_DeleteParentTask_RecursiveDeletesChildren()
{
// Arrange
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child",
ParentTaskId = parentId,
LastModificationTime = DateTime.UtcNow
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
var deleteRequest = new SyncRequest
{
Deletes = new List<Guid> { parentId }
};
// Act
await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Equal(2, response.Count);
Assert.All(response, t => Assert.True(t.IsDeleted));
}
/// <summary>
/// 测试 Code 字段在创建和返回时正确映射。
/// </summary>
[Fact]
public async Task SyncAsync_Code_IsStoredAndReturned()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Task with Code",
Code = "5",
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("5", response.Tasks.First().Code);
}
/// <summary>
/// 测试 Code 字段在更新时正确覆盖。
/// </summary>
[Fact]
public async Task SyncAsync_Code_IsUpdated()
{
// Arrange
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Original",
Code = "1",
LastModificationTime = DateTime.UtcNow
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated",
Code = "99",
LastModificationTime = DateTime.UtcNow.AddMinutes(1)
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("99", response.Tasks.First().Code);
}
/// <summary>
/// 测试新任务 Code 为空字符串时的处理。
/// </summary>
[Fact]
public async Task SyncAsync_Code_EmptyString_StoredAsEmpty()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "No Code",
Code = string.Empty,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal(string.Empty, response.Tasks.First().Code);
}
/// <summary>
/// 测试同一批次中包含子任务引用新创建的父任务(父子任务 Id 均已知)。
/// 验证第二遍处理子任务时能正确找到已创建父任务的 Id。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChild_SameBatch_ChildFindsNewParent()
{
// Arrange
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child",
ParentTaskId = parentId,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
await _service.SyncAsync(_testUserId, request, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Equal(2, response.Count);
var child = response.First(t => t.Title == "Child");
Assert.Equal(parentId, child.ParentTaskId);
var parent = response.First(t => t.Title == "Parent");
Assert.Null(parent.ParentTaskId);
}
/// <summary>
/// 测试去重:同一批次中相同 Id 出现多次,只保留最后一条。
/// </summary>
[Fact]
public async Task SyncAsync_DuplicateId_LastWins()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "First Version",
Code = "1",
LastModificationTime = DateTime.UtcNow.AddMinutes(-5)
},
new CloudTaskUpsert
{
Id = taskId,
Title = "Last Version",
Code = "2",
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("Last Version", response.Tasks.First().Title);
Assert.Equal("2", response.Tasks.First().Code);
}
/// <summary>
/// 测试更新已有任务时不会触发 UNIQUE 约束冲突。
/// </summary>
[Fact]
public async Task SyncAsync_UpdateExistingTask_NoUniqueConstraintViolation()
{
// Arrange - 先创建一个任务
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Original",
Priority = TaskPriority.High,
IsCompleted = false,
LastModificationTime = DateTime.UtcNow.AddMinutes(-5)
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
// Act - 用相同的 Id 再次同步(更新),模拟客户端重发
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated",
Priority = TaskPriority.Low,
IsCompleted = true,
LastModificationTime = DateTime.UtcNow // 较新时间
}
}
};
// Assert - 不应抛出 DbUpdateException
var response = await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
Assert.Single(response.Tasks);
Assert.Equal("Updated", response.Tasks.First().Title);
}
/// <summary>
/// 测试同一批次中父子任务(均有 Id),先处理父再处理子时无 UNIQUE 冲突。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildSameBatch_NoUniqueConstraintViolation()
{
// Arrange - 父子任务均在同一批次中,模拟客户端新建父子任务后同步
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent Task",
Priority = TaskPriority.High,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null // 与实际错误场景一致
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child Task",
Priority = TaskPriority.Medium,
IsCompleted = false,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
}
}
};
// Act & Assert - 不应抛出 UNIQUE constraint failed
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
}
/// <summary>
/// 测试 Title 为空或仅空白的 upsert 被过滤掉。
/// </summary>
[Fact]
public async Task SyncAsync_EmptyTitle_FilteredOut()
{
// Arrange
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = " ",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = "",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = "Valid Task",
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("Valid Task", response.Tasks.First().Title);
}
}
+31
View File
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
<ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" />
</ItemGroup>
</Project>
+123
View File
@@ -0,0 +1,123 @@
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Tests;
/// <summary>
/// TaskEntity 单元测试。
/// </summary>
public class TaskEntityTests
{
/// <summary>
/// 测试 TaskEntity 继承自 FullAuditedEntityWithUser。
/// </summary>
[Fact]
public void TaskEntity_InheritsFromFullAuditedEntityWithUser()
{
// Arrange & Act
var task = new TaskEntity();
// Assert
Assert.IsAssignableFrom<FullAuditedEntityWithUser<Guid, UserEntity>>(task);
}
/// <summary>
/// 测试 TaskEntity 主键为 Guid 类型。
/// </summary>
[Fact]
public void TaskEntity_IdIsGuid()
{
// Arrange & Act
var task = new TaskEntity();
// Assert
Assert.Equal(Guid.Empty, task.Id);
}
/// <summary>
/// 测试 TaskEntity 默认值。
/// </summary>
[Fact]
public void TaskEntity_DefaultValues()
{
// Arrange & Act
var task = new TaskEntity();
// Assert
Assert.Equal(string.Empty, task.Title);
Assert.Equal(TaskPriority.Medium, task.Priority);
Assert.False(task.IsCompleted);
Assert.Null(task.ParentTaskId);
Assert.Empty(task.SubTasks);
Assert.False(task.IsDeleted);
Assert.Null(task.DeletionTime);
Assert.Null(task.DeleterId);
}
/// <summary>
/// 测试创建任务时生成 Guid。
/// </summary>
[Fact]
public void TaskEntity_NewInstance_HasUniqueId()
{
// Arrange & Act
var task1 = new TaskEntity { Id = Guid.NewGuid() };
var task2 = new TaskEntity { Id = Guid.NewGuid() };
// Assert
Assert.NotEqual(task1.Id, task2.Id);
}
}
/// <summary>
/// FullAuditedEntityWithUser 基类单元测试。
/// </summary>
public class FullAuditedEntityWithUserTests
{
/// <summary>
/// 测试 ABP 审计字段默认值。
/// </summary>
[Fact]
public void FullAuditedEntityWithUser_DefaultValues()
{
// Arrange & Act
var entity = new TestAuditedEntity();
// Assert
Assert.Equal(Guid.Empty, entity.Id);
Assert.False(entity.IsDeleted);
Assert.Null(entity.DeletionTime);
Assert.Null(entity.DeleterId);
Assert.Null(entity.ExtraProperties);
Assert.Null(entity.ConcurrencyStamp);
Assert.Equal(default, entity.CreationTime);
Assert.Null(entity.CreatorId);
Assert.Null(entity.LastModificationTime);
Assert.Null(entity.LastModifierId);
}
/// <summary>
/// 测试 IUser 接口实现。
/// </summary>
[Fact]
public void UserEntity_ImplementsIUser()
{
// Arrange & Act
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = "testuser"
};
// Assert
Assert.IsAssignableFrom<IUser<Guid>>(user);
Assert.Equal("testuser", user.UserName);
}
/// <summary>
/// 测试子类。
/// </summary>
private class TestAuditedEntity : FullAuditedEntityWithUser<Guid, UserEntity>
{
}
}
+2 -1
View File
@@ -1,2 +1,3 @@
# MAUI 模式:API 与云同步共用本地后端代理目标 # MAUI 模式:所有请求代理到 MAUI 嵌入式 WebServer
# 云同步请求由嵌入式服务器的 CloudSyncProxyMiddleware 转发到远程 Host
VITE_API_TARGET=http://localhost:5057 VITE_API_TARGET=http://localhost:5057
+4 -11
View File
@@ -32,17 +32,10 @@ const cloudClient = axios.create({
}); });
cloudClient.interceptors.request.use((config) => { cloudClient.interceptors.request.use((config) => {
// 根据运行模式决定 baseURL // MAUI 模式:使用同源请求,由嵌入式 WebServer 的云同步代理中间件转发到远程 Host。
// - Host 模式(Vite dev):使用同源请求,通过 Vite proxy 到 Host // 不再从 localStorage 读取 serverUrl —— 地址由嵌入式服务器的 CloudSyncProxySettings 管理。
// - MAUI 模式:使用配置的 serverUrl 连接外部云同步服务器 // Host 模式(Vite dev):也使用同源请求,通过 Vite proxy 转发到 Host。
const isMaui = (window as any).__IS_MAUI__ === true; // 两种模式均不设置 baseURL,axios 使用浏览器默认同源。
if (isMaui) {
const { serverUrl } = CloudSyncStorage.loadSettings();
if (serverUrl) {
config.baseURL = serverUrl;
}
}
// Host 模式下不设置 baseURLaxios 使用浏览器默认同源
const session = CloudSyncStorage.loadSession(); const session = CloudSyncStorage.loadSession();
if (session?.accessToken) { if (session?.accessToken) {
+64 -23
View File
@@ -1,12 +1,18 @@
import cloudClient from './cloudClient'; import cloudClient from './cloudClient';
import CloudSyncStorage from '../services/cloudSyncStorage'; import CloudSyncStorage from '../services/cloudSyncStorage';
import type { Task } from '../types/task'; import type { Task, TaskPriority } from '../types/task';
/**
* 登录请求。
*/
export interface CloudLoginRequest { export interface CloudLoginRequest {
userName: string; userName: string;
password: string; password: string;
} }
/**
* 登录响应。
*/
export interface CloudLoginResponse { export interface CloudLoginResponse {
accessToken: string; accessToken: string;
expiresAtUtc: string; expiresAtUtc: string;
@@ -15,14 +21,27 @@ export interface CloudLoginResponse {
permissions: string[]; permissions: string[];
} }
/**
* 云同步任务条目(与 CloudTaskItem DTO 对应)。
* - id 为 Guid 序列化后的字符串
* - 包含 ABP 审计字段
*/
export interface CloudTaskItem { export interface CloudTaskItem {
id: number; id: string;
title: string; title: string;
priority: 0 | 1 | 2; priority: TaskPriority;
isCompleted: boolean; isCompleted: boolean;
createdAtUtc: string; code: string;
updatedAtUtc: string; parentTaskId: string | null;
parentTaskId?: number | null;
// === ABP 审计字段 ===
creationTime: string;
creatorId: string | null;
lastModificationTime: string | null;
lastModifierId: string | null;
isDeleted: boolean;
deletionTime: string | null;
deleterId: string | null;
} }
/** /**
@@ -30,11 +49,13 @@ export interface CloudTaskItem {
* 注意:id 为 null/undefined 表示新建任务,由服务端分配 ID。 * 注意:id 为 null/undefined 表示新建任务,由服务端分配 ID。
*/ */
export interface CloudTaskUpsert { export interface CloudTaskUpsert {
id?: number | null; id?: string | null;
title: string; title: string;
priority: 0 | 1 | 2; priority: TaskPriority;
isCompleted: boolean; isCompleted: boolean;
parentTaskId?: number | null; code: string;
parentTaskId?: string | null;
lastModificationTime?: string | null;
} }
/** /**
@@ -42,7 +63,7 @@ export interface CloudTaskUpsert {
*/ */
export interface CloudSyncRequest { export interface CloudSyncRequest {
upserts: CloudTaskUpsert[]; upserts: CloudTaskUpsert[];
deletes: number[]; deletes: string[];
} }
/** /**
@@ -53,6 +74,9 @@ export interface CloudSyncResponse {
tasks: CloudTaskItem[]; tasks: CloudTaskItem[];
} }
/**
* 安全策略响应。
*/
export interface SecurityPolicyResponse { export interface SecurityPolicyResponse {
allowPersist: boolean; allowPersist: boolean;
allowSync: boolean; allowSync: boolean;
@@ -74,16 +98,22 @@ export interface ProbeServerUrlResponse {
* 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。 * 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。
*/ */
export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
const map = new Map<number, Task>(); const map = new Map<string, Task>();
for (const item of items) { for (const item of items) {
map.set(item.id, { map.set(item.id, {
id: item.id, id: item.id,
title: item.title ?? '', title: item.title ?? '',
priority: item.priority ?? 1, priority: item.priority ?? 1,
isCompleted: Boolean(item.isCompleted), isCompleted: Boolean(item.isCompleted),
createdAt: item.createdAtUtc, code: item.code ?? '',
updatedAt: item.updatedAtUtc, parentTaskId: item.parentTaskId,
parentTaskId: item.parentTaskId ?? undefined, creationTime: item.creationTime,
creatorId: item.creatorId,
lastModificationTime: item.lastModificationTime,
lastModifierId: item.lastModifierId,
isDeleted: item.isDeleted,
deletionTime: item.deletionTime,
deleterId: item.deleterId,
subTasks: [], subTasks: [],
}); });
} }
@@ -91,7 +121,7 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
const roots: Task[] = []; const roots: Task[] = [];
for (const task of map.values()) { for (const task of map.values()) {
const parentId = task.parentTaskId; const parentId = task.parentTaskId;
if (typeof parentId === 'number' && map.has(parentId)) { if (parentId != null && map.has(parentId)) {
map.get(parentId)!.subTasks.push(task); map.get(parentId)!.subTasks.push(task);
} else { } else {
roots.push(task); roots.push(task);
@@ -103,25 +133,29 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
/** /**
* 把前端的任务树结构扁平化为 CloudTaskUpsert 列表,用于发送到云端。 * 把前端的任务树结构扁平化为 CloudTaskUpsert 列表,用于发送到云端。
* - id > 0 表示已同步到云端的任务(保留 id 用于更新) * - id 存在且有效(Guid 格式)表示已同步到云端的任务(保留 id 用于更新)
* - id <= 0 或未设置表示本地新建任务(id 置为 null,由服务端分配新 ID) * - id 为空或无效表示本地新建任务(id 置为 null/undefined,由服务端分配新 ID
* - 使用 seen 集合去重:同一任务(按 id 或 hash)只发送一次 * - 使用 seen 集合去重:同一任务(按 id)只发送一次
*/ */
export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => { export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => {
const result: CloudTaskUpsert[] = []; const result: CloudTaskUpsert[] = [];
const seenIds = new Set<number | null>(); const seenIds = new Set<string | null>();
const walk = (items: Task[]) => { const walk = (items: Task[]) => {
for (const task of items) { for (const task of items) {
const id = task.id > 0 ? task.id : null; // 有效 Guid 字符串作为已同步任务的标识,否则视为新建任务
const id = isValidGuid(task.id) ? task.id : null;
// 去重:同一 id 只保留第一次出现 // 去重:同一 id 只保留第一次出现
if (id != null && seenIds.has(id)) continue; if (id != null && seenIds.has(id)) continue;
if (id != null) seenIds.add(id); if (id != null) seenIds.add(id);
result.push({ result.push({
id, id,
title: task.title, title: task.title,
priority: task.priority as 0 | 1 | 2, priority: task.priority as TaskPriority,
isCompleted: Boolean(task.isCompleted), isCompleted: Boolean(task.isCompleted),
parentTaskId: typeof task.parentTaskId === 'number' && task.parentTaskId > 0 ? task.parentTaskId : null, code: task.code ?? '',
parentTaskId: isValidGuid(task.parentTaskId ?? '') ? task.parentTaskId : null,
// 确保 lastModificationTime 不为 null,否则服务端 LWW 会拒绝客户端变更
lastModificationTime: task.lastModificationTime || new Date().toISOString(),
}); });
if (task.subTasks && task.subTasks.length > 0) { if (task.subTasks && task.subTasks.length > 0) {
walk(task.subTasks); walk(task.subTasks);
@@ -132,6 +166,13 @@ export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] =>
return result; return result;
}; };
/**
* 判断字符串是否为有效 Guid 格式。
*/
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);
}
/** /**
* 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。 * 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。
*/ */
@@ -180,7 +221,7 @@ export const cloudSyncApi = {
* 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。 * 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。
* 注意:该端点需要已登录 + 二次认证(step-up)。 * 注意:该端点需要已登录 + 二次认证(step-up)。
*/ */
async syncTasks(localTasks: Task[], deleteIds: number[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> { async syncTasks(localTasks: Task[], deleteIds: string[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> {
const upserts = flattenTasksToCloudUpserts(localTasks); const upserts = flattenTasksToCloudUpserts(localTasks);
const body: CloudSyncRequest = { upserts, deletes: deleteIds }; const body: CloudSyncRequest = { upserts, deletes: deleteIds };
const response = await cloudClient.post<CloudSyncResponse>('/sync/', body); const response = await cloudClient.post<CloudSyncResponse>('/sync/', body);
+26 -13
View File
@@ -2,6 +2,7 @@ import apiClient from './client';
import type { Task, CreateTaskDto, UpdateTaskDto, ApiResponse } from '../types/task'; import type { Task, CreateTaskDto, UpdateTaskDto, ApiResponse } from '../types/task';
import LocalStorageService from '../services/localStorageService'; import LocalStorageService from '../services/localStorageService';
import { normalizeTask, normalizeTasks } from '../services/taskNormalizer'; import { normalizeTask, normalizeTasks } from '../services/taskNormalizer';
import { generateGuid } from '../utils/guid';
export const taskApi = { export const taskApi = {
/** /**
@@ -63,7 +64,7 @@ export const taskApi = {
* @param id 任务 ID * @param id 任务 ID
* @returns 包含任务对象或 null 的响应对象 * @returns 包含任务对象或 null 的响应对象
*/ */
async getTask(id: number): Promise<ApiResponse<Task | null>> { async getTask(id: string): Promise<ApiResponse<Task | null>> {
if (!LocalStorageService.isOnline()) { if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks(); const localTasks = LocalStorageService.loadTasks();
const task = localTasks.find(t => t.id === id); const task = localTasks.find(t => t.id === id);
@@ -102,19 +103,31 @@ export const taskApi = {
* @returns 包含新创建任务的响应对象 * @returns 包含新创建任务的响应对象
*/ */
async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> { async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> {
const now = new Date().toISOString();
// 离线时从本地已有任务生成 code
const localTasks = LocalStorageService.loadTasks();
const maxCode = localTasks.reduce((max, t) => {
const n = parseInt(t.code ?? '0', 10);
return isNaN(n) ? max : Math.max(max, n);
}, 0);
const newTask: Task = { const newTask: Task = {
id: LocalStorageService.getNextTaskId(), id: generateGuid(),
title: dto.title, title: dto.title,
priority: dto.priority, priority: dto.priority,
isCompleted: false, isCompleted: false,
createdAt: new Date().toISOString(), code: String(maxCode + 1),
updatedAt: new Date().toISOString(), creationTime: now,
parentTaskId: dto.parentTaskId, creatorId: null,
lastModificationTime: now,
lastModifierId: null,
isDeleted: false,
deletionTime: null,
deleterId: null,
parentTaskId: dto.parentTaskId ?? null,
subTasks: [] subTasks: []
}; };
if (!LocalStorageService.isOnline()) { if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
localTasks.unshift(newTask); localTasks.unshift(newTask);
LocalStorageService.saveTasks(localTasks); LocalStorageService.saveTasks(localTasks);
@@ -167,13 +180,13 @@ export const taskApi = {
* @param dto 更新任务的数据传输对象 * @param dto 更新任务的数据传输对象
* @returns 包含更新后任务的响应对象 * @returns 包含更新后任务的响应对象
*/ */
async updateTask(id: number, dto: UpdateTaskDto): Promise<ApiResponse<Task>> { async updateTask(id: string, dto: UpdateTaskDto): Promise<ApiResponse<Task>> {
const updateDto = dto; const updateDto = dto;
if (!LocalStorageService.isOnline()) { if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks(); const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id); const taskIndex = localTasks.findIndex(t => t.id === id);
if (taskIndex !== -1) { if (taskIndex !== -1) {
if (dto.title) { if (dto.title) {
localTasks[taskIndex].title = dto.title; localTasks[taskIndex].title = dto.title;
@@ -181,7 +194,7 @@ export const taskApi = {
if (dto.priority !== undefined) { if (dto.priority !== undefined) {
localTasks[taskIndex].priority = dto.priority; localTasks[taskIndex].priority = dto.priority;
} }
localTasks[taskIndex].updatedAt = new Date().toISOString(); localTasks[taskIndex].lastModificationTime = new Date().toISOString();
LocalStorageService.saveTasks(localTasks); LocalStorageService.saveTasks(localTasks);
const syncStatus = LocalStorageService.loadSyncStatus(); const syncStatus = LocalStorageService.loadSyncStatus();
@@ -234,14 +247,14 @@ export const taskApi = {
* @param id 任务 ID * @param id 任务 ID
* @returns 包含更新后任务的响应对象 * @returns 包含更新后任务的响应对象
*/ */
async toggleComplete(id: number): Promise<ApiResponse<Task>> { async toggleComplete(id: string): Promise<ApiResponse<Task>> {
if (!LocalStorageService.isOnline()) { if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks(); const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id); const taskIndex = localTasks.findIndex(t => t.id === id);
if (taskIndex !== -1) { if (taskIndex !== -1) {
localTasks[taskIndex].isCompleted = !localTasks[taskIndex].isCompleted; localTasks[taskIndex].isCompleted = !localTasks[taskIndex].isCompleted;
localTasks[taskIndex].updatedAt = new Date().toISOString(); localTasks[taskIndex].lastModificationTime = new Date().toISOString();
LocalStorageService.saveTasks(localTasks); LocalStorageService.saveTasks(localTasks);
const syncStatus = LocalStorageService.loadSyncStatus(); const syncStatus = LocalStorageService.loadSyncStatus();
@@ -294,7 +307,7 @@ export const taskApi = {
* @param id 任务 ID * @param id 任务 ID
* @returns 包含操作结果的响应对象 * @returns 包含操作结果的响应对象
*/ */
async deleteTask(id: number): Promise<ApiResponse<object>> { async deleteTask(id: string): Promise<ApiResponse<object>> {
if (!LocalStorageService.isOnline()) { if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks(); const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id); const taskIndex = localTasks.findIndex(t => t.id === id);
@@ -144,6 +144,7 @@
* 通过 CustomEvent 与其他组件通信(cloudSyncStateChanged、cloudSyncPullTasksRequested)。 * 通过 CustomEvent 与其他组件通信(cloudSyncStateChanged、cloudSyncPullTasksRequested)。
*/ */
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import CloudSyncStorage from '../services/cloudSyncStorage'; import CloudSyncStorage from '../services/cloudSyncStorage';
import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync'; import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync';
import LocalStorageService, { type SyncStatus } from '../services/localStorageService'; import LocalStorageService, { type SyncStatus } from '../services/localStorageService';
@@ -216,17 +217,30 @@ const canSync = computed(() => {
// ========== 核心方法 ========== // ========== 核心方法 ==========
/** /**
* 从本地存储加载设置状态 * 从本地 API 加载云同步服务端地址。
* 并在已登录时自动获取安全策略和同步状态 * 启用状态仍从 localStorage 读取(客户端偏好)。
* 并在已登录时自动获取安全策略和同步状态。
*/ */
const loadLocalState = () => { const loadLocalState = async () => {
refreshSession(); refreshSession();
const settings = CloudSyncStorage.loadSettings();
localEnabled.value = settings.enabled; // 从嵌入式 WebServer 的本地 API 获取服务端地址
serverUrlSaved.value = settings.serverUrl; try {
serverUrlInput.value = settings.serverUrl; const res = await axios.get('/api/cloud-sync/settings');
const serverUrl = (res.data as any)?.serverUrl ?? '';
refreshSyncStatus(); // 加载同步状态 serverUrlSaved.value = serverUrl;
serverUrlInput.value = serverUrl;
} catch {
// 嵌入式服务器未就绪或接口不存在,回退到 localStorage
const settings = CloudSyncStorage.loadSettings();
serverUrlSaved.value = settings.serverUrl;
serverUrlInput.value = settings.serverUrl;
}
// 启用状态保持 localStorage(客户端偏好)
localEnabled.value = CloudSyncStorage.loadSettings().enabled;
refreshSyncStatus();
if (isLoggedIn.value) { if (isLoggedIn.value) {
refreshPolicy(); refreshPolicy();
} }
@@ -367,7 +381,7 @@ const emitCloudSyncStateChanged = () => {
const event = new CustomEvent('cloudSyncStateChanged', { const event = new CustomEvent('cloudSyncStateChanged', {
detail: { detail: {
enabled: settings.enabled, enabled: settings.enabled,
serverUrl: settings.serverUrl, serverUrl: serverUrlSaved.value || settings.serverUrl,
isLoggedIn: Boolean(session?.accessToken), isLoggedIn: Boolean(session?.accessToken),
}, },
}); });
@@ -410,10 +424,17 @@ const saveServerUrl = async () => {
} }
serverUrlSaved.value = parsed.normalized; serverUrlSaved.value = parsed.normalized;
CloudSyncStorage.saveSettings({
serverUrl: serverUrlSaved.value, // 写入嵌入式 WebServer 的本地 API(持久化到 appsettings.json
enabled: localEnabled.value, try {
}); await axios.post('/api/cloud-sync/settings', { serverUrl: serverUrlSaved.value });
} catch {
// 回退到 localStorage
CloudSyncStorage.saveSettings({
serverUrl: serverUrlSaved.value,
enabled: localEnabled.value,
});
}
if (parsed.warn) { if (parsed.warn) {
showToast(parsed.warn, 'success'); showToast(parsed.warn, 'success');
@@ -423,7 +444,7 @@ const saveServerUrl = async () => {
isProbing.value = true; isProbing.value = true;
try { try {
// 调用 Host 的探测接口,由服务端发起探测(避免浏览器直连的 CORS/证书/网络限制 // 探测走同源代理(cloudSyncApi 通过 cloudClient → 同源 → 代理中间件 → 远程 Host
const probeResponse = await cloudSyncApi.probeServerUrl(serverUrlSaved.value); const probeResponse = await cloudSyncApi.probeServerUrl(serverUrlSaved.value);
probeResult.value = { probeResult.value = {
type: probeResponse.type as ProbeType, type: probeResponse.type as ProbeType,
+4 -4
View File
@@ -18,7 +18,7 @@
<div class="task-info"> <div class="task-info">
<span class="task-title">{{ task.title }}</span> <span class="task-title">{{ task.title }}</span>
<div class="task-meta"> <div class="task-meta">
<span class="task-id task-id--compact">#{{ task.id }}</span> <span class="task-id task-id--compact">#{{ task.code }}</span>
<span class="task-priority task-priority--compact">{{ priorityShortText }}</span> <span class="task-priority task-priority--compact">{{ priorityShortText }}</span>
<span class="task-created">创建{{ createdAtText }}</span> <span class="task-created">创建{{ createdAtText }}</span>
</div> </div>
@@ -98,7 +98,7 @@ const props = defineProps<Props>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'updated', task: Task): void; (e: 'updated', task: Task): void;
(e: 'deleted', id: number): void; (e: 'deleted', id: string): void;
(e: 'subtask-created', task: Task): void; (e: 'subtask-created', task: Task): void;
}>(); }>();
@@ -145,7 +145,7 @@ const formatDateTime = (value: string): string => {
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`; return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}; };
const createdAtText = computed(() => formatDateTime(props.task.createdAt)); const createdAtText = computed(() => formatDateTime(props.task.creationTime));
const toggleExpand = () => { const toggleExpand = () => {
isExpanded.value = !isExpanded.value; isExpanded.value = !isExpanded.value;
@@ -207,7 +207,7 @@ const handleSubTaskUpdated = (updatedTask: Task) => {
} }
}; };
const handleSubTaskDeleted = (subTaskId: number) => { const handleSubTaskDeleted = (subTaskId: string) => {
if (props.task.subTasks) { if (props.task.subTasks) {
props.task.subTasks = props.task.subTasks.filter(t => t.id !== subTaskId); props.task.subTasks = props.task.subTasks.filter(t => t.id !== subTaskId);
emit('updated', props.task); emit('updated', props.task);
+66 -11
View File
@@ -286,21 +286,21 @@ const sortTasks = (tasks: Task[]): Task[] => {
const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority]; const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority];
comparison = priorityDiff; comparison = priorityDiff;
if (comparison === 0) { if (comparison === 0) {
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
} }
break; break;
case 'completedAt': { case 'completedAt': {
const aVal = a.isCompleted ? new Date(a.updatedAt).getTime() : 0; const aVal = a.isCompleted && a.lastModificationTime ? new Date(a.lastModificationTime).getTime() : 0;
const bVal = b.isCompleted ? new Date(b.updatedAt).getTime() : 0; const bVal = b.isCompleted && b.lastModificationTime ? new Date(b.lastModificationTime).getTime() : 0;
comparison = bVal - aVal; comparison = bVal - aVal;
if (comparison === 0) { if (comparison === 0) {
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
} }
break; break;
} }
case 'createdAt': case 'createdAt':
default: default:
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
break; break;
} }
@@ -414,12 +414,12 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
return false; return false;
}; };
const handleTaskDeleted = (taskId: number) => { const handleTaskDeleted = (taskId: string) => {
deleteTaskFromTree(tasks.value, taskId); deleteTaskFromTree(tasks.value, taskId);
lastSyncTime.value = Date.now(); lastSyncTime.value = Date.now();
}; };
const deleteTaskFromTree = (tasks: Task[], taskId: number): boolean => { const deleteTaskFromTree = (tasks: Task[], taskId: string): boolean => {
for (let i = 0; i < tasks.length; i++) { for (let i = 0; i < tasks.length; i++) {
if (tasks[i].id === taskId) { if (tasks[i].id === taskId) {
tasks.splice(i, 1); tasks.splice(i, 1);
@@ -486,7 +486,10 @@ const handleCloudSyncStateChanged = () => {
/** /**
* 立即同步:将本地任务推送到云端并获取云端最新数据。 * 立即同步:将本地任务推送到云端并获取云端最新数据。
* 通过 cloudSyncApi.syncTasks 调用 POST /sync/:推送 upserts 列表,云端返回最终状态。 * 策略:服务端为单一真相源,但合并而非盲目替换 ——
* 1. 上传所有本地任务(upserts)
* 2. 服务端按 LWW 合并并返回全量
* 3. 以服务端列表为准,同时保留服务端未返回的本地任务(防止数据丢失)
*/ */
const syncNow = async () => { const syncNow = async () => {
if (isSyncing.value) return; if (isSyncing.value) return;
@@ -495,9 +498,61 @@ const syncNow = async () => {
const localTasks = tasks.value.length > 0 const localTasks = tasks.value.length > 0
? tasks.value ? tasks.value
: LocalStorageService.loadTasks(); : LocalStorageService.loadTasks();
// 上传统计所有本地任务 ID,用于后续合并校验
const localTaskIds = new Set<string>();
const collectIds = (items: Task[]) => {
for (const t of items) {
if (t.id) localTaskIds.add(String(t.id));
if (t.subTasks?.length) collectIds(t.subTasks);
}
};
collectIds(localTasks);
const { tasks: cloudTasks, serverTimeUtc } = await cloudSyncApi.syncTasks(localTasks); const { tasks: cloudTasks, serverTimeUtc } = await cloudSyncApi.syncTasks(localTasks);
tasks.value = cloudTasks;
LocalStorageService.saveTasks(cloudTasks); // 构建云端任务 ID 索引
const cloudTaskMap = new Map<string, Task>();
const collectCloudIds = (items: Task[]) => {
for (const t of items) {
if (t.id) cloudTaskMap.set(String(t.id), t);
if (t.subTasks?.length) collectCloudIds(t.subTasks);
}
};
collectCloudIds(cloudTasks);
// 合并策略:云端已有的任务以云端为准;云端没有的本地任务保留(附时间戳确保下次能被接受)
const mergedTasks: Task[] = [...cloudTasks];
const nowIso = new Date().toISOString();
// 递归收集云端缺失的本地任务,正确构建子任务树结构
const collectMissingLocals = (items: Task[]): Task[] => {
const result: Task[] = [];
for (const t of items) {
const id = String(t.id);
if (cloudTaskMap.has(id)) {
// 任务已在云端返回中 → 跳过(云端版本已在 mergedTasks 中,含正确子树)
continue;
}
// 任务不在云端返回中 → 保留本地版本,递归处理子任务
const preserved: Task = {
...t,
lastModificationTime: t.lastModificationTime || nowIso,
};
if (t.subTasks?.length) {
preserved.subTasks = collectMissingLocals(t.subTasks);
}
result.push(preserved);
console.warn(`[CloudSync] 任务 ${id} ("${t.title}") 未在云端返回列表中找到,已保留本地副本`);
}
return result;
};
const missingLocals = collectMissingLocals(localTasks);
mergedTasks.push(...missingLocals);
tasks.value = mergedTasks;
LocalStorageService.saveTasks(mergedTasks);
const syncStatus = LocalStorageService.loadSyncStatus(); const syncStatus = LocalStorageService.loadSyncStatus();
const serverTimestamp = serverTimeUtc ? new Date(serverTimeUtc).getTime() : Date.now(); const serverTimestamp = serverTimeUtc ? new Date(serverTimeUtc).getTime() : Date.now();
syncStatus.lastSyncTime = serverTimestamp; syncStatus.lastSyncTime = serverTimestamp;
@@ -506,7 +561,7 @@ const syncNow = async () => {
LocalStorageService.saveSyncStatus(syncStatus); LocalStorageService.saveSyncStatus(syncStatus);
lastSyncTime.value = syncStatus.lastSyncTime; lastSyncTime.value = syncStatus.lastSyncTime;
pendingChanges.value = 0; pendingChanges.value = 0;
LocalStorageService.syncTaskIdCounter(cloudTasks); LocalStorageService.syncTaskIdCounter(mergedTasks);
showToast('同步成功', 'success'); showToast('同步成功', 'success');
} catch (error) { } catch (error) {
console.error('Sync failed:', error); console.error('Sync failed:', error);
+11 -12
View File
@@ -26,25 +26,24 @@ const toTaskPriority = (value: unknown): TaskPriority => {
return 1; return 1;
}; };
const toIsoString = (value: unknown): string => {
if (typeof value === 'string' && value.trim()) return value;
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
return new Date().toISOString();
};
export const normalizeTask = (task: any): Task => { export const normalizeTask = (task: any): Task => {
const normalized: any = { ...(task ?? {}) }; const normalized: any = { ...(task ?? {}) };
if (typeof normalized.id === 'string' && /^\d+$/.test(normalized.id)) { // id 现在是 Guid 字符串,不再进行类型转换
normalized.id = Number(normalized.id); // 保留原始 id 即可(可能是 string 或 number,取决于数据来源)
}
normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? ''); normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? '');
normalized.priority = toTaskPriority(normalized.priority); normalized.priority = toTaskPriority(normalized.priority);
normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted); normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted);
normalized.createdAt = toIsoString(normalized.createdAt); normalized.code = typeof normalized.code === 'string' ? normalized.code : '';
normalized.updatedAt = toIsoString(normalized.updatedAt);
// 统一字段名:createdAt/updatedAt(旧) -> creationTime/lastModificationTime(新)
if (!normalized.creationTime) {
normalized.creationTime = normalized.createdAt || normalized.creationTime || new Date().toISOString();
}
if (!normalized.lastModificationTime) {
normalized.lastModificationTime = normalized.updatedAt || normalized.lastModificationTime || null;
}
if (normalized.parentTaskId === null) { if (normalized.parentTaskId === null) {
normalized.parentTaskId = undefined; normalized.parentTaskId = undefined;
+45 -6
View File
@@ -1,26 +1,65 @@
export type TaskPriority = 0 | 1 | 2; export type TaskPriority = 0 | 1 | 2;
/**
* Todo 待办项(与 CloudTaskItem DTO 一一对应)。
* - 主键 id 为 Guid 序列化后的字符串
* - 包含 ABP 审计字段,用于云同步冲突判断
*/
export interface Task { export interface Task {
id: number; // === 业务字段 ===
/** 主键(Guid 序列化) */
id: string;
/** 标题 */
title: string; title: string;
/** 优先级 */
priority: TaskPriority; priority: TaskPriority;
/** 是否完成 */
isCompleted: boolean; isCompleted: boolean;
createdAt: string; /** 任务编号(由后端自动分配,用户级自增字符串) */
updatedAt: string; code: string;
parentTaskId?: number; /** 父任务 ID(Guid 类型,null 表示顶级任务) */
parentTaskId: string | 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[]; subTasks: Task[];
} }
/**
* 创建 Todo 待办项请求(客户端 → 服务端,新建时不带 id)
*/
export interface CreateTaskDto { export interface CreateTaskDto {
title: string; title: string;
priority: TaskPriority; priority: TaskPriority;
parentTaskId?: number; /** 父任务 IDGuid 字符串) */
parentTaskId?: string;
} }
/**
* 更新 Todo 待办项请求(客户端 → 服务端,必须带 id)
*/
export interface UpdateTaskDto { export interface UpdateTaskDto {
id: number; /** 主键(Guid 字符串) */
id: string;
title?: string; title?: string;
priority?: TaskPriority; priority?: TaskPriority;
isCompleted?: boolean;
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {
+19
View File
@@ -0,0 +1,19 @@
/**
* Guid 工具函数。
* 用于客户端创建临时任务 ID 和验证 Guid 格式。
*/
/**
* 生成新的 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);
}