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:
@@ -179,12 +179,12 @@ Hua.Todo/
|
||||
## 📚 更多文档
|
||||
|
||||
### 用户与开发者手册
|
||||
- [技术栈与模块说明](docs/manual/技术栈与模块.md)
|
||||
- [版本更新历史](docs/manual/版本记录.md)
|
||||
- [技术设计文档](docs/manual/技术设计文档.md)
|
||||
- [代码规范文档](docs/manual/代码规范文档.md)
|
||||
- [部署文档](docs/manual/部署文档.md)
|
||||
- [其他信息](docs/manual/其他信息.md)
|
||||
- [技术栈与模块说明](docs/manual/03-技术栈与模块.md)
|
||||
- [版本更新历史](docs/manual/06-版本记录.md)
|
||||
- [技术设计文档](docs/manual/01-技术设计文档.md)
|
||||
- [代码规范文档](docs/manual/04-代码规范文档.md)
|
||||
- [部署文档](docs/manual/05-部署文档.md)
|
||||
- [其他信息](docs/manual/07-其他信息.md)
|
||||
|
||||
### 项目进度与需求
|
||||
- [产品需求文档](docs/project/产品需求文档.md)
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
Hua.Todo/
|
||||
├── docs/ # 文档目录
|
||||
│ ├── manual/ # 用户/开发者手册
|
||||
│ │ ├── 技术栈与模块.md
|
||||
│ │ ├── 版本记录.md
|
||||
│ │ ├── 技术设计文档.md(本文件)
|
||||
│ │ ├── 代码规范文档.md
|
||||
│ │ └── 部署文档.md
|
||||
│ │ ├── 03-技术栈与模块.md
|
||||
│ │ ├── 06-版本记录.md
|
||||
│ │ ├── 01-技术设计文档.md(本文件)
|
||||
│ │ ├── 04-代码规范文档.md
|
||||
│ │ └── 05-部署文档.md
|
||||
│ └── project/ # 项目进度/需求文档
|
||||
│ ├── 产品需求文档.md
|
||||
│ └── ...
|
||||
@@ -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 Token(GUID 格式 SessionId)
|
||||
2. 查 `UserSessions` 表:校验 SessionId 存在且 `ExpiresAtUtc > DateTime.UtcNow`
|
||||
3. 加载用户角色,通过 `IRolePermissionMapper` 获取权限列表
|
||||
4. 检查 `SecurityPolicies.AllowSync`:若为 `false`,从权限中移除 `sync:write`
|
||||
5. 构造 `ClaimsIdentity`(含 `sub`=UserId、`role`、`perm` Claims),注入 `HttpContext.User`
|
||||
|
||||
---
|
||||
|
||||
## 四、RBAC 权限模型
|
||||
|
||||
### 4.1 权限点定义(6 个)
|
||||
|
||||
| 权限点 | 常量 | 说明 |
|
||||
|---|---|---|
|
||||
| `tasks:read` | CloudPermissions.TasksRead | 读取 Todo 待办项 |
|
||||
| `tasks:write` | CloudPermissions.TasksWrite | 写入 Todo 待办项 |
|
||||
| `sync:write` | CloudPermissions.SyncWrite | 执行同步操作(受 `AllowSync` 策略叠加限制) |
|
||||
| `policy:read` | CloudPermissions.PolicyRead | 读取安全策略 |
|
||||
| `policy:write` | CloudPermissions.PolicyWrite | 修改安全策略 |
|
||||
| `users:manage` | CloudPermissions.UsersManage | 管理用户(Admin 专属) |
|
||||
|
||||
### 4.2 角色与权限映射
|
||||
|
||||
| 角色 | 权限 |
|
||||
|---|---|
|
||||
| `admin` | 全部 6 个权限 |
|
||||
| `user`(默认) | `tasks:read`、`tasks:write`、`sync:write`、`policy:read` |
|
||||
| `readonly` | `tasks:read`、`policy:read` |
|
||||
| `nosync` | `tasks:read`、`tasks:write`、`policy:read` |
|
||||
|
||||
> `sync:write` 权限在鉴权时还会受 `SecurityPolicies.AllowSync` 二次过滤:若策略禁止同步,即使角色拥有 `sync:write` 也会被移除。
|
||||
|
||||
### 4.3 端点权限要求
|
||||
|
||||
| 端点 | 方法 | 所需权限 |
|
||||
|---|---|---|
|
||||
| `/tasks` | GET | `tasks:read` |
|
||||
| `/sync` | POST | `sync:write` |
|
||||
| `/security/policy` | GET | `policy:read` |
|
||||
| `/security/policy` | PUT | `policy:write` |
|
||||
| `/admin/*` | GET/POST/DELETE | `users:manage` |
|
||||
| `/cloud-sync/probe` | POST | —(匿名访问) |
|
||||
|
||||
---
|
||||
|
||||
## 五、安全策略与可控落盘
|
||||
|
||||
### 5.1 SecurityPolicies 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `AllowPersist` | bool | 是否允许客户端持久化 Todo 数据到本地存储 |
|
||||
| `AllowSync` | bool | 是否允许该用户执行同步写入 |
|
||||
| `IsTrustedDeviceOnly` | bool | 是否仅限受信任终端(预留) |
|
||||
|
||||
每个用户一条策略记录,与 `UserEntity` 一对一。
|
||||
|
||||
### 5.2 客户端落盘规则(强制)
|
||||
|
||||
| 服务端策略 | 客户端行为 |
|
||||
|---|---|
|
||||
| `allowPersist = true` | 正常落盘:Todo 数据 + 登录凭据(Token)写入 `localStorage` / SQLite |
|
||||
| `allowPersist = false` | **内存模式**:Todo 数据与凭据仅保留在内存中,退出应用后全部清除 |
|
||||
|
||||
**策略切换时的处理**:
|
||||
|
||||
- `true → false`:立即清空已落盘数据,提示用户"已切换为内存模式,退出后数据不保留"
|
||||
- `false → true`:恢复持久化,将当前内存数据写入存储
|
||||
|
||||
### 5.3 落盘数据范围
|
||||
|
||||
"禁止落盘"时,以下数据均不得写入任何持久化介质:
|
||||
|
||||
- Todo 数据(列表/详情)
|
||||
- 登录凭据(accessToken / 会话标识)
|
||||
- 同步状态(上次同步时间、待同步队列)
|
||||
- 安全策略缓存
|
||||
|
||||
---
|
||||
|
||||
## 六、同步工作流
|
||||
|
||||
### 6.1 完整流程
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 配置地址 │ ──→ │ 登录 │ ──→ │ 拉取全量 │
|
||||
│ (探测可达) │ │ (获取Token) │ │ (GET /tasks) │
|
||||
└──────────────┘ └──────────────┘ └──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 返回全量 │ ←── │ 增删改同步 │ ←── │ 本地编辑 │
|
||||
│ (SyncResponse)│ │ (POST /sync) │ │ (upsert+del)│
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 6.2 同步策略
|
||||
|
||||
#### 6.2.1 核心原则
|
||||
|
||||
- **任务唯一标识是 `id`**:`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
|
||||
- **`updatedAtUtc` 是冲突判断字段**:用于解决"同一任务被多端修改时哪个变更更新"的问题
|
||||
- **服务端存储唯一真实源(Source of Truth)**:所有设备最终都收敛到服务端数据
|
||||
- **字段级 Last-Write-Wins(LWW)合并**:同一字段的多设备并发修改,以 `updatedAtUtc` 较新者为准
|
||||
- **逻辑删除(Tombstone)**:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致
|
||||
- **增量同步**:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量
|
||||
|
||||
#### 6.2.2 变更追踪机制
|
||||
|
||||
每个 `CloudTaskItem` 包含以下时间戳字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `createdAtUtc` | 创建时间(服务端分配,永不改变) |
|
||||
| `updatedAtUtc` | 最后一次修改时间(每次字段变更时更新) |
|
||||
| `deletedAtUtc` | 逻辑删除时间(为 `null` 表示未删除) |
|
||||
|
||||
> `updatedAtUtc` 由**请求发起方**(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。
|
||||
|
||||
#### 6.2.3 冲突解决规则
|
||||
|
||||
当客户端提交的任务与服务端现有任务发生 `id` 冲突时,按以下规则处理:
|
||||
|
||||
| 场景 | 解决规则 |
|
||||
|---|---|
|
||||
| 同一 `id`,客户端 `updatedAtUtc` **更新** | 服务端接受客户端版本,覆盖对应字段 |
|
||||
| 同一 `id`,服务端 `updatedAtUtc` **更新** | 服务端拒绝客户端版本,保留服务端版本 |
|
||||
| 同一 `id`,两者 `updatedAtUtc` **相同** | 服务端优先(保守策略) |
|
||||
|
||||
**字段级合并示例**:
|
||||
- 设备 A 修改标题,设备 B 修改截止日期
|
||||
- 服务端对两个字段分别取 `updatedAtUtc` 较新者,合并出最终结果
|
||||
- 两个设备的修改都被保留,不会互相覆盖
|
||||
|
||||
#### 6.2.4 父子任务处理
|
||||
|
||||
- **删除**:删除父任务时,其子任务一并标记为已删除(递归)
|
||||
- **创建**:子任务的 `parentTaskId` 在上传时若为 `null`(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射
|
||||
- **重建父子关系**:若原父任务被删除后重建,子任务的 `parentTaskId` 引用仍指向原父 ID,不会自动指向新父
|
||||
|
||||
#### 6.2.5 Tombstone 保留策略
|
||||
|
||||
- `deletedAtUtc` 非 `null` 的任务在服务端保留至少 **30 天**
|
||||
- 超过 30 天后由服务端垃圾收集(物理删除)
|
||||
- 客户端同步时拉取全量(含 Tombstone),本地过滤不展示 `deletedAtUtc != null` 的任务
|
||||
|
||||
#### 6.2.6 服务端处理流程
|
||||
|
||||
`POST /sync` 服务端执行顺序:
|
||||
|
||||
```
|
||||
1. 解析 upserts 和 deletes
|
||||
2. 对 deletes 中的每个 id:
|
||||
- 递归标记该任务及所有子任务 deletedAtUtc = now
|
||||
3. 对 upserts 按 updatedAtUtc 降序排列(较新的先处理)
|
||||
4. 对每个 upsert 任务:
|
||||
a. 若 id 在服务端不存在 → 创建新记录(分配正向 id)
|
||||
b. 若 id 存在但服务端 updatedAtUtc 更新 → 跳过(保留服务端)
|
||||
c. 若 id 存在且客户端 updatedAtUtc >= 服务端 updatedAtUtc → 字段级合并更新
|
||||
5. 返回当前用户全量任务(含 Tombstone)
|
||||
```
|
||||
|
||||
> 排序处理的目的:确保最新的变更优先被采纳。
|
||||
|
||||
#### 6.2.7 关于"服务端为准"的正确理解
|
||||
|
||||
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
|
||||
|
||||
1. **客户端上传阶段**:将 `pendingUpserts` + `pendingDeletes` 提交到服务器
|
||||
2. **服务器合并阶段**:按 LWW 规则合并客户端提交与服务器现有数据
|
||||
3. **客户端覆盖阶段**:用服务器返回的全量数据覆盖本地
|
||||
|
||||
由于客户端在第 1 步已经把本地所有变更提交,服务器在第 2 步已经把客户端的变更合并进权威状态,第 3 步用权威状态覆盖本地是安全的——**不会丢失任何已提交的变更**。
|
||||
|
||||
真正会丢失的场景是:客户端在离线状态下修改了任务 A,但没有在联网后发起同步就直接断开连接。这种情况下,离线修改会保留在本地 `pendingUpserts` 中,下次联网同步时会正常提交。
|
||||
|
||||
### 6.3 SyncRequest / SyncResponse 结构
|
||||
|
||||
**请求**(`POST /sync`):
|
||||
```json
|
||||
{
|
||||
"upserts": [
|
||||
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null, "updatedAtUtc": "2026-04-06T17:00:00Z" },
|
||||
{ "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null, "updatedAtUtc": "2026-04-06T17:30:00Z" }
|
||||
],
|
||||
"deletes": [2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
- `id` 为服务端已知 ID(`id > 0`)时执行更新;`id` 为 `null` / 0 / 负数时创建新记录
|
||||
- `updatedAtUtc` 必填,客户端在发起变更时写入本地时间(UTC)
|
||||
- `deletes` 为待逻辑删除的任务 ID 数组
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"serverTimeUtc": "2026-04-06T17:39:30.0281279Z",
|
||||
"tasks": [
|
||||
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null, "createdAtUtc": "2026-04-01T00:00:00Z", "updatedAtUtc": "2026-04-06T17:00:00Z", "deletedAtUtc": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `tasks` 返回当前用户**全量数据**(含 `deletedAtUtc != null` 的 Tombstone)
|
||||
- 客户端用响应全量覆盖本地数据,本地过滤不展示已删除任务
|
||||
|
||||
### 6.4 同步按钮调用规则
|
||||
|
||||
**触发入口**:主界面"同步"按钮(`TaskList.vue`),绑定 `syncNow()` 方法。
|
||||
|
||||
#### 6.4.1 前置检查
|
||||
|
||||
1. 检查 `isSyncing` 状态,防止并发重复调用
|
||||
2. 检查登录状态(未登录则提示先登录)
|
||||
|
||||
#### 6.4.2 变更检测(增量同步核心)
|
||||
|
||||
本地维护 `pendingUpserts` 和 `pendingDeletes` 两个集合:
|
||||
|
||||
| 操作 | 记录时机 | 记录内容 |
|
||||
|---|---|---|
|
||||
| 新增任务 | 创建时 | `{ id: 临时负数, title, priority, ..., updatedAtUtc: 当前时间 }` |
|
||||
| 修改任务 | 任何字段变更时 | `{ id, 全部字段, updatedAtUtc: 当前时间 }` |
|
||||
| 删除任务 | 点击删除时 | `{ id }`(从 pendingUpserts 移除,加入 pendingDeletes)|
|
||||
|
||||
> 每次用户操作后,更新本地 `updatedAtUtc`。使用 `seenIds` 集合去重,同一 `id` 只保留最新一条记录。
|
||||
|
||||
#### 6.4.3 数据准备
|
||||
|
||||
1. **合并 pendingUpserts**:
|
||||
- 调用 `flattenTasksToCloudUpserts(localTasks, pendingUpserts)` 生成 upsert 列表
|
||||
- **ID 映射规则**:
|
||||
- `id > 0`:保留原 ID(服务端已知,执行更新)
|
||||
- `id <= 0` 或未设置:置为 `null`(服务端分配新 ID,执行创建)
|
||||
- 按 `updatedAtUtc` 升序排列(较早的先发送,便于服务端 LWW 判断)
|
||||
|
||||
2. **生成 deletes 列表**:
|
||||
- 从 `pendingDeletes` 提取待删除 ID
|
||||
- 去重:`deletes = [...new Set(pendingDeletes.map(d => d.id))]`
|
||||
|
||||
#### 6.4.4 请求发送
|
||||
|
||||
- 端点:`POST /sync/`
|
||||
- 请求体:`{ upserts: CloudTaskUpsert[], deletes: number[] }`
|
||||
- 携带 `Authorization: Bearer {accessToken}`
|
||||
|
||||
#### 6.4.5 响应处理
|
||||
|
||||
1. **解析全量响应**:
|
||||
- 调用 `buildTaskTreeFromCloudItems(response.tasks)` 将扁平响应转为前端树结构
|
||||
- 过滤 `deletedAtUtc == null` 的任务用于展示
|
||||
|
||||
2. **更新本地状态**:
|
||||
- `tasks.value = cloudTasks`(覆盖式更新,确保与服务端一致)
|
||||
- `LocalStorageService.saveTasks(cloudTasks)`
|
||||
- 清空 `pendingUpserts` 和 `pendingDeletes`
|
||||
|
||||
3. **更新同步状态**:
|
||||
- `lastSyncTime`:使用响应中的 `serverTimeUtc`
|
||||
- `syncError = null`
|
||||
|
||||
#### 6.4.6 错误处理
|
||||
|
||||
| 错误类型 | 处理策略 |
|
||||
|---|---|
|
||||
| 401 未授权 | 清除本地会话,弹出重新登录提示 |
|
||||
| 403 禁止 | Toast 提示权限不足 |
|
||||
| 网络错误 | 重试(指数退避,最多 3 次),仍失败则保留 pending 数据下次同步 |
|
||||
|
||||
#### 6.4.7 完整流程图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ syncNow() 触发 │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 前置检查:isSyncing? 已登录? │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. 变更检测:pendingUpserts + pendingDeletes │
|
||||
│ - 遍历 tasks,收集 dirty 项(updatedAtUtc > lastSyncTime)│
|
||||
│ - 新增/删除操作直接追加到 pending │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. 数据准备 │
|
||||
│ - flattenTasksToCloudUpserts() │
|
||||
│ - seenIds 去重 │
|
||||
│ - 按 updatedAtUtc 排序 │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4. POST /sync { upserts, deletes } │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 5. 响应处理 │
|
||||
│ - buildTaskTreeFromCloudItems() │
|
||||
│ - tasks.value = cloudTasks │
|
||||
│ - saveTasks() │
|
||||
│ - 清空 pendingUpserts / pendingDeletes │
|
||||
│ - lastSyncTime = serverTimeUtc │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、API 契约总览
|
||||
|
||||
### 7.1 通用约定
|
||||
|
||||
- Base URL:客户端同源请求,无需外部配置
|
||||
- 认证:`Authorization: Bearer {accessToken}`
|
||||
- 统一错误响应:
|
||||
```json
|
||||
{ "code": "ERROR_CODE", "message": "Human readable message." }
|
||||
```
|
||||
|
||||
### 7.2 错误码
|
||||
|
||||
| 错误码 | HTTP 状态 | 含义 |
|
||||
|---|---|---|
|
||||
| `UNAUTHORIZED` | 401 | 未登录或会话失效 |
|
||||
| `FORBIDDEN` | 403 | 权限不足(RBAC 拒绝 / 策略拒绝) |
|
||||
| `BAD_REQUEST` | 400 | 请求参数不合法 |
|
||||
| `NOT_FOUND` | 404 | 资源不存在 |
|
||||
|
||||
### 7.3 完整端点清单
|
||||
|
||||
| 路由 | 方法 | 权限 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `/auth/bootstrap` | POST | 无(仅首次) | 初始化管理员账号 |
|
||||
| `/auth/login` | POST | 匿名 | 登录,返回 AccessToken |
|
||||
| `/auth/logout` | POST | 登录即可 | 登出,删除会话 |
|
||||
| `/auth/change-password` | POST | 登录即可 | 修改当前用户密码 |
|
||||
| `/tasks` | GET | `tasks:read` | 获取当前用户全量任务 |
|
||||
| `/sync` | POST | `sync:write` | 上传 upsert + deletes,返回最新全量 |
|
||||
| `/security/policy` | GET | `policy:read` | 获取当前用户安全策略 |
|
||||
| `/security/policy` | PUT | `policy:write` | 修改当前用户安全策略 |
|
||||
| `/cloud-sync/probe` | POST | 匿名 | 服务端代理探测目标 URL 可达性 |
|
||||
| `/admin/users` | GET/POST/DELETE | `users:manage` | 用户管理 |
|
||||
| `/admin/sessions` | GET/DELETE | `users:manage` | 会话管理 |
|
||||
| `/admin/audit-logs` | GET | `users:manage` | 审计日志查询 |
|
||||
|
||||
---
|
||||
|
||||
## 八、前端实现规则
|
||||
|
||||
### 8.1 cloudClient.ts(Axios 实例)
|
||||
|
||||
- 与通用 `client.ts`(baseURL 含 `/api`)解耦,云同步专用。
|
||||
- **请求拦截器**:MAUI 模式下从 `CloudSyncStorage` 读取 `serverUrl`,Host 模式不设 baseURL 走同源;自动附加 `Bearer {accessToken}`。
|
||||
- **响应拦截器**:
|
||||
- 401 → 清除本地会话,弹出重新登录提示。
|
||||
- 403 `FORBIDDEN` → Toast 提示权限不足。
|
||||
- 其他错误 → 统一 Toast 通知。
|
||||
|
||||
### 8.2 cloudSync.ts(API 封装)
|
||||
|
||||
提供 `cloudSyncApi` 对象,6 个方法:
|
||||
|
||||
| 方法 | 端点 | 核心逻辑 |
|
||||
|---|---|---|
|
||||
| `login()` | `/auth/login` | 登录后保存 Session 到 `CloudSyncStorage` |
|
||||
| `logout()` | `/auth/logout` | 清除本地会话与缓存 |
|
||||
| `getTasks()` | `/tasks` | 拉取全量 `CloudTaskItem[]`,通过 `buildTaskTreeFromCloudItems` 将扁平数据转为前端 Task 树 |
|
||||
| `syncTasks()` | `/sync` | 通过 `flattenTasksToCloudUpserts` 将 Task 树扁平化为 upsert 列表,携带 deletes 提交 |
|
||||
| `getSecurityPolicy()` | `/security/policy` | 获取策略,驱动落盘/内存模式切换 |
|
||||
| `probeServerUrl()` | `/cloud-sync/probe` | 由服务端代理探测目标 URL |
|
||||
|
||||
### 8.3 CloudSyncSettingsDialog.vue(UI 组件)
|
||||
|
||||
功能区块:
|
||||
|
||||
- **同步开关**:checkbox 控制,校验地址已保存方可开启。
|
||||
- **服务端地址**:输入 + 规范化 + 保存并探测(`probeServerUrl`)。
|
||||
- **登录区**:用户名/密码 → `login()` → 刷新策略与状态。
|
||||
- **已登录区**:会话摘要、连接状态、安全策略展示、登出 + 立即同步。
|
||||
- 通过 CustomEvent(`cloudSyncStateChanged` / `cloudSyncTasksRequested`)与其他组件通信。
|
||||
|
||||
---
|
||||
|
||||
## 九、服务端代理探测规则
|
||||
|
||||
由研发工单 08 引入,`POST /cloud-sync/probe` 由 Host 代理探测目标 URL:
|
||||
|
||||
- 输入:`{ "targetUrl": "https://example.com" }`
|
||||
- 输出:`{ "isReachable", "httpStatus", "isHttps", "title", "description", "type" }`(type: `success` / `warn` / `error`)
|
||||
- 安全约束:
|
||||
- 限制目标 URL 仅允许 HTTP/HTTPS 公网地址,禁止探测 localhost / 内网 IP(防 SSRF)。
|
||||
- 建议加频率限制(如每分钟 3 次)。
|
||||
|
||||
---
|
||||
|
||||
## 十、审计日志
|
||||
|
||||
所有关键安全事件写入 `AuditLogs` 表:
|
||||
|
||||
- 登录成功 / 失败(含 IP、终端信息)
|
||||
- 安全策略变更
|
||||
- 用户管理操作(创建 / 删除 / 重置密码)
|
||||
|
||||
通过 `/admin/audit-logs` 端点查询,支持按时间、用户、事件类型过滤。
|
||||
|
||||
---
|
||||
|
||||
## 十一、约束与风险
|
||||
|
||||
| 约束 / 风险 | 缓解措施 |
|
||||
|---|---|
|
||||
| Token 吊销(会话非纯无状态) | `UserSessions` 表管理;登出即删记录,Token 立即失效 |
|
||||
| HTTPS 强制 | 所有安全通信必须基于 TLS,防中间人攻击窃取 Token |
|
||||
| 暴力破解 | `POST /auth/login` 建议加 Rate Limiting |
|
||||
| MAUI 端不暴露云同步端点 | DI 注册边界严格执行:`AddCloudSyncServer()` 仅 Host 调用 |
|
||||
| `allowPersist = false` 时数据丢失风险 | UI 强提示;退出时若未同步则二次确认 |
|
||||
| 密码存储 | 使用 Argon2id 哈希(含 Salt),不存明文 |
|
||||
| SSRF(探测端点) | `CloudProbeService` 限制目标为公网地址,禁止内网探测 |
|
||||
|
||||
---
|
||||
|
||||
> **相关文档**:
|
||||
> - PRD v1.2.0:[产品需求文档-1.2.0.md](../project/产品需求文档-1.2.0.md)
|
||||
> - 研发工单总览:[00-工单总览.md](../project/研发工单-v1.2.0/00-工单总览.md)
|
||||
> - 服务端基础能力:[04-CloudSync-服务端基础能力.md](../project/研发工单-v1.2.0/04-CloudSync-服务端基础能力.md)
|
||||
> - 客户端工作流:[05-CloudSync-客户端配置与工作流.md](../project/研发工单-v1.2.0/05-CloudSync-客户端配置与工作流.md)
|
||||
> - 安全与可控落盘:[06-CloudSync-安全与可控落盘.md](../project/研发工单-v1.2.0/06-CloudSync-安全与可控落盘.md)
|
||||
> - 安全设计方案:[06.1-CloudSync-服务端安全设计方案.md](../project/研发工单-v1.2.0/06.1-CloudSync-服务端安全设计方案.md)
|
||||
> - 同源 Host 重构:[08-cloud_sync_refactor_plan.md](../project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md)
|
||||
> - 技术设计文档:[技术设计文档.md](./01-技术设计文档.md)
|
||||
@@ -9,6 +9,10 @@
|
||||
- v1.1.0:MAUI + WebView 跨平台版本
|
||||
- v1.2.0 (规划中):Linux 支持与增强功能
|
||||
|
||||
### v1.2.8 (2026-06-14)
|
||||
|
||||
- **文档**:新增 [任务同步规则.md](./02-任务同步规则.md),汇总 Todo 待办项云同步的架构、API 契约、认证鉴权、同步工作流、安全策略与可控落盘等完整规则。
|
||||
|
||||
### v1.2.8 (2026-04-13)
|
||||
|
||||
- **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。
|
||||
@@ -35,8 +39,8 @@
|
||||
- **Windows WebView2 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
|
||||
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
|
||||
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
|
||||
- **文档与部署指南**:新增 `docs/manual/部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。
|
||||
- **用户文档完善**:在规划中新增了 `docs/manual/新手指南.md` 和 `docs/manual/用户指南.md`。
|
||||
- **文档与部署指南**:新增 `docs/manual/05-部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。
|
||||
- **用户文档完善**:在规划中新增了 `docs/manual/08-新手指南.md` 和 `docs/manual/09-用户指南.md`。
|
||||
27→
|
||||
28→### v1.1.1 (2026-04-06)
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增"云同步设置"弹窗:地址校验+保存探测、登录/登出、登录后拉取云端 Todo 数据并在主界面只读展示 |
|
||||
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
|
||||
| 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)
|
||||
|
||||
|
||||
@@ -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 CloudTaskItem(C# → 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>
|
||||
/// 父任务 ID(Guid)。
|
||||
/// </summary>
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
|
||||
// === ABP 审计字段 ===
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreationTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人 ID。
|
||||
/// </summary>
|
||||
public Guid? CreatorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后修改时间(用于 LWW 冲突判断)。
|
||||
/// </summary>
|
||||
public DateTime? LastModificationTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后修改人 ID。
|
||||
/// </summary>
|
||||
public Guid? LastModifierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 软删除标记。
|
||||
/// </summary>
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除时间。
|
||||
/// </summary>
|
||||
public DateTime? DeletionTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除人 ID。
|
||||
/// </summary>
|
||||
public Guid? DeleterId { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 CloudTaskUpsert(C# → JSON 请求)
|
||||
|
||||
```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 默认使用 PascalCase(ABP 标准),通过 `[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 改为 Guid,JSON 序列化为字符串 |
|
||||
| `parentTaskId` | `number \| undefined` | `string \| null` | 外键类型同步变更 |
|
||||
| `createdAt` | `string` | **移除** | 替换为 `creationTime` |
|
||||
| `updatedAt` | `string` | **移除** | 替换为 `lastModificationTime` |
|
||||
| `subTasks` | `Task[]` | `Task[]` | 类型不变,递归结构 |
|
||||
| — | — | `creationTime: string` | 新增:ABP 创建时间 |
|
||||
| — | — | `creatorId: string \| null` | 新增:创建人 ID |
|
||||
| — | — | `lastModificationTime: string \| null` | 新增:最后修改时间(LWW 冲突判断) |
|
||||
| — | — | `lastModifierId: string \| null` | 新增:最后修改人 ID |
|
||||
| — | — | `isDeleted: boolean` | 新增:软删除标记 |
|
||||
| — | — | `deletionTime: string \| null` | 新增:删除时间 |
|
||||
| — | — | `deleterId: string \| null` | 新增:删除人 ID |
|
||||
|
||||
### 5.2 task.ts 目标定义
|
||||
|
||||
```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>());
|
||||
|
||||
// 待上传的删除任务 ID(Guid 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_Tasks(ABP 规范),DbContext 配置为 "Tasks"
|
||||
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. 新增 Guid Id 列(临时名)
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "NewId",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: Guid.NewGuid());
|
||||
|
||||
// 2. 将旧 int Id 数据迁移到 NewId
|
||||
// (需要手动 SQL 脚本或数据迁移)
|
||||
|
||||
// 3. 删除旧 Id 列,重命名 NewId 为 Id
|
||||
migrationBuilder.DropColumn("Id", "T_Tasks");
|
||||
migrationBuilder.RenameColumn("NewId", "T_Tasks", "Id");
|
||||
|
||||
// 4. 新增 ABP 审计字段
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExtraProperties",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ConcurrencyStamp",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreationTime",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: DateTime.UtcNow);
|
||||
|
||||
migrationBuilder.AddColumn<Guid?>(
|
||||
name: "CreatorId",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime?>(
|
||||
name: "LastModificationTime",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid?>(
|
||||
name: "LastModifierId",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDeleted",
|
||||
table: "T_Tasks",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime?>(
|
||||
name: "DeletionTime",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid?>(
|
||||
name: "DeleterId",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
// 5. ParentTaskId 从 int 改为 Guid
|
||||
migrationBuilder.DropColumn("ParentTaskId", "T_Tasks");
|
||||
migrationBuilder.AddColumn<Guid?>(
|
||||
name: "ParentTaskId",
|
||||
table: "T_Tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 回滚逻辑...
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:此迁移为破坏性变更,建议在离线环境充分测试后再部署到生产环境。
|
||||
|
||||
---
|
||||
|
||||
## 七、同步策略(与 02-任务同步规则.md 保持一致)
|
||||
|
||||
> 本章节同步策略逻辑完全遵循 [02-任务同步规则.md](../../manual/02-任务同步规则.md) 6.2 节,字段名使用 ABP 标准。
|
||||
|
||||
### 7.1 核心原则
|
||||
|
||||
- **任务唯一标识是 `id`**:`id` 是任务实体的唯一稳定标识,不可依赖时间戳做身份判断
|
||||
- **`lastModificationTime` 是冲突判断字段**:用于解决"同一任务被多端修改时哪个变更更新"的问题
|
||||
- **服务端存储唯一真实源(Source of Truth)**:所有设备最终都收敛到服务端数据
|
||||
- **字段级 Last-Write-Wins(LWW)合并**:同一字段的多设备并发修改,以 `lastModificationTime` 较新者为准
|
||||
- **逻辑删除(Tombstone)**:删除操作标记为"已删除",而非物理删除,保证多端删除语义一致
|
||||
- **增量同步**:每次同步只上传本端变更(pendingUpserts + pendingDeletes),不上传全量
|
||||
|
||||
### 7.2 变更追踪机制
|
||||
|
||||
每个 `CloudTaskItem` 包含以下时间戳字段:
|
||||
|
||||
| ABP 字段 | 说明 |
|
||||
|---|---|
|
||||
| `creationTime` | 创建时间(服务端分配,永不改变) |
|
||||
| `lastModificationTime` | 最后一次修改时间(每次字段变更时更新) |
|
||||
| `isDeleted` | 软删除标记 |
|
||||
| `deletionTime` | 逻辑删除时间(为 `null` 表示未删除) |
|
||||
| `creatorId` | 创建人 ID |
|
||||
| `lastModifierId` | 最后修改人 ID |
|
||||
|
||||
> `lastModificationTime` 由**请求发起方**(客户端或服务端)在发起变更时写入,存储到服务端后不再覆盖(除非有更新的变更)。
|
||||
|
||||
### 7.3 冲突解决规则
|
||||
|
||||
当客户端提交的任务与服务端现有任务发生 `id` 冲突时,按以下规则处理:
|
||||
|
||||
| 场景 | 解决规则 |
|
||||
|---|---|
|
||||
| 同一 `id`,客户端 `lastModificationTime` **更新** | 服务端接受客户端版本,覆盖对应字段 |
|
||||
| 同一 `id`,服务端 `lastModificationTime` **更新** | 服务端拒绝客户端版本,保留服务端版本 |
|
||||
| 同一 `id`,两者 `lastModificationTime` **相同** | 服务端优先(保守策略) |
|
||||
|
||||
**字段级合并示例**:
|
||||
- 设备 A 修改标题,设备 B 修改优先级
|
||||
- 服务端对两个字段分别取 `lastModificationTime` 较新者,合并出最终结果
|
||||
- 两个设备的修改都被保留,不会互相覆盖
|
||||
|
||||
### 7.4 父子任务处理
|
||||
|
||||
- **删除**:删除父任务时,其子任务一并标记为已删除(递归)
|
||||
- **创建**:子任务的 `parentTaskId` 在上传时若为 `null`(新创建任务),服务端分配 ID 后,第一遍返回临时 ID 映射;第二遍利用映射完成父子关系重映射
|
||||
- **重建父子关系**:若原父任务被删除后重建,子任务的 `parentTaskId` 引用仍指向原父 ID,不会自动指向新父
|
||||
|
||||
### 7.5 Tombstone 保留策略
|
||||
|
||||
- `deletionTime` 非 `null` 的任务在服务端保留至少 **30 天**
|
||||
- 超过 30 天后由服务端垃圾收集(物理删除)
|
||||
- 客户端同步时拉取全量(含 Tombstone),本地过滤不展示 `deletionTime != null` 的任务
|
||||
|
||||
### 7.6 服务端处理流程
|
||||
|
||||
`POST /sync` 服务端执行顺序:
|
||||
|
||||
```
|
||||
1. 解析 upserts 和 deletes
|
||||
2. 对 deletes 中的每个 id:
|
||||
- 递归标记该任务及所有子任务 deletionTime = now, isDeleted = true
|
||||
3. 对 upserts 按 lastModificationTime 降序排列(较新的先处理)
|
||||
4. 对每个 upsert 任务:
|
||||
a. 若 id 在服务端不存在 → 创建新记录(分配 Guid)
|
||||
b. 若 id 存在但服务端 lastModificationTime 更新 → 跳过(保留服务端)
|
||||
c. 若 id 存在且客户端 lastModificationTime >= 服务端 lastModificationTime → 字段级合并更新
|
||||
5. 返回当前用户全量任务(含 Tombstone)
|
||||
```
|
||||
|
||||
> 排序处理的目的:确保最新的变更优先被采纳。
|
||||
|
||||
### 7.7 关于"服务端为准"的正确理解
|
||||
|
||||
"服务端为准"并不意味着客户端会丢失数据。完整的同步流程是:
|
||||
|
||||
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.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Hua.Todo.Application.CloudSync.Auth;
|
||||
using Hua.Todo.Application.CloudSync.Models;
|
||||
using Hua.Todo.Application.CloudSync.Services;
|
||||
@@ -342,4 +344,36 @@ public static class CloudSyncEndpointExtensions
|
||||
}
|
||||
|
||||
#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-Type(StringContent 已设置,但确保不被覆盖)
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
namespace Hua.Todo.Application.CloudSync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 云同步任务条目。
|
||||
/// 云同步任务条目(ABP 标准)。
|
||||
/// </summary>
|
||||
public class CloudTaskItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务 ID(服务端分配)。
|
||||
/// 任务 ID(服务端分配,Guid)。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
[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; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否完成。
|
||||
/// </summary>
|
||||
[JsonPropertyName("isCompleted")]
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// 任务编号(用户级自增字符串)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// 父任务 ID(Guid)。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
[JsonPropertyName("parentTaskId")]
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
|
||||
// === ABP 审计字段 ===
|
||||
|
||||
/// <summary>
|
||||
/// 父任务 ID(v1.2.0 同步可先不使用)。
|
||||
/// 创建时间。
|
||||
/// </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>
|
||||
@@ -51,43 +145,14 @@ public class SyncRequest
|
||||
/// <summary>
|
||||
/// 新增或更新的任务列表。
|
||||
/// </summary>
|
||||
[JsonPropertyName("upserts")]
|
||||
public List<CloudTaskUpsert> Upserts { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 需要删除的任务 ID 列表。
|
||||
/// 需要逻辑删除的任务 ID 列表。
|
||||
/// </summary>
|
||||
public List<int> 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; }
|
||||
[JsonPropertyName("deletes")]
|
||||
public List<Guid> Deletes { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -98,11 +163,12 @@ public class SyncResponse
|
||||
/// <summary>
|
||||
/// 服务端时间(UTC)。
|
||||
/// </summary>
|
||||
[JsonPropertyName("serverTimeUtc")]
|
||||
public DateTime ServerTimeUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户的任务全量。
|
||||
/// 当前用户的任务全量(含 Tombstone)。
|
||||
/// </summary>
|
||||
[JsonPropertyName("tasks")]
|
||||
public List<CloudTaskItem> Tasks { get; set; } = new();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Hua.Todo.Application.CloudSync.Models;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Hua.Todo.Core.Entities;
|
||||
@@ -7,6 +8,7 @@ namespace Hua.Todo.Application.CloudSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 云同步任务服务(按用户隔离)。
|
||||
/// 实现 ABP 风格审计字段和 LWW 冲突解决策略。
|
||||
/// </summary>
|
||||
public class CloudTaskSyncService
|
||||
{
|
||||
@@ -22,17 +24,19 @@ public class CloudTaskSyncService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定用户的任务全量。
|
||||
/// 获取指定用户的任务全量(含 Tombstone)。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>任务列表。</returns>
|
||||
/// <returns>任务列表(含已删除任务)。</returns>
|
||||
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 使用 IgnoreQueryFilters 获取包括已删除任务的全量
|
||||
var tasks = await _dbContext.Tasks
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => t.UserId == userId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.OrderByDescending(t => t.CreationTime)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return tasks.Select(MapToItem).ToList();
|
||||
@@ -40,6 +44,9 @@ public class CloudTaskSyncService
|
||||
|
||||
/// <summary>
|
||||
/// 执行同步(增改删),并返回最新全量。
|
||||
/// - 对 deletes 递归标记为已删除(Tombstone)
|
||||
/// - 对 upserts 按 lastModificationTime 降序处理,较新的先处理
|
||||
/// - LWW 冲突解决:客户端 lastModificationTime >= 服务端时接受客户端版本
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="request">同步请求。</param>
|
||||
@@ -49,132 +56,109 @@ public class CloudTaskSyncService
|
||||
{
|
||||
request ??= new SyncRequest();
|
||||
|
||||
// 1. 处理删除(逻辑删除,Tombstone)
|
||||
if (request.Deletes.Count > 0)
|
||||
{
|
||||
var deleteIds = request.Deletes.Distinct().ToList();
|
||||
var toDelete = await _dbContext.Tasks
|
||||
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (toDelete.Count > 0)
|
||||
foreach (var id in deleteIds)
|
||||
{
|
||||
_dbContext.Tasks.RemoveRange(toDelete);
|
||||
await SoftDeleteTaskRecursiveAsync(userId, id, cancellationToken);
|
||||
}
|
||||
|
||||
// 保存删除操作
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 处理 upserts(按 lastModificationTime 降序,较新的先处理)
|
||||
if (request.Upserts.Count > 0)
|
||||
{
|
||||
// 去重:同一 Id 保留最后一条(后发送覆盖先发送)
|
||||
var uniqueUpserts = request.Upserts
|
||||
.GroupBy(u => u.Id)
|
||||
.Select(g => g.Last())
|
||||
// 去重策略:
|
||||
// - 已有 Id 的(非 null):按 Id 分组,每组保留最后一条(后发送覆盖先发送)
|
||||
// - 全新任务(Id == null):全部保留,不参与去重
|
||||
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))
|
||||
.OrderByDescending(u => u.LastModificationTime) // 较新的先处理
|
||||
.ToList();
|
||||
|
||||
// 预先查询该用户所有已存在的任务 ID
|
||||
var existingIds = await _dbContext.Tasks
|
||||
.Where(t => t.UserId == userId)
|
||||
.Select(t => t.Id)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
// 使用事务包裹,确保读取与写入的一致性
|
||||
// 注意:SQLite 默认 BEGIN DEFERRED 可能导致并发写入方读取到过期快照,产生 UNIQUE 冲突
|
||||
// ProcessUpsertAsync 中的 DB 级别二次查重可解决跨请求重试场景;
|
||||
// 对于极端并发写入冲突,SaveChangesAsync 处有重试保护
|
||||
const int maxSaveRetries = 3;
|
||||
|
||||
// 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射)
|
||||
var clientIdToEntity = new Dictionary<int, TaskEntity>();
|
||||
|
||||
// 第一遍:处理根任务(ParentTaskId == null)
|
||||
foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue))
|
||||
for (int retry = 0; ; retry++)
|
||||
{
|
||||
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
|
||||
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
|
||||
if (entity != null)
|
||||
// 在事务内查询已有任务,确保读取到最新已提交数据
|
||||
var existingTasks = await _dbContext.Tasks
|
||||
.IgnoreQueryFilters()
|
||||
.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();
|
||||
entity.Priority = upsert.Priority;
|
||||
entity.IsCompleted = upsert.IsCompleted;
|
||||
entity.ParentTaskId = null;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
await ProcessUpsertAsync(userId, upsert, existingTasks, clientIdToEntity, cancellationToken);
|
||||
}
|
||||
|
||||
// 保存根任务,获取服务器分配的 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; // 成功,退出重试循环
|
||||
}
|
||||
|
||||
if (entity == null)
|
||||
catch (DbUpdateException ex) when (retry < maxSaveRetries - 1
|
||||
&& ex.InnerException is Microsoft.Data.Sqlite.SqliteException se
|
||||
&& se.SqliteErrorCode == 19
|
||||
&& se.Message.Contains("UNIQUE constraint"))
|
||||
{
|
||||
// 新建任务;EF Core 会自动分配新的服务器 ID
|
||||
entity = new TaskEntity
|
||||
{
|
||||
UserId = userId,
|
||||
Title = upsert.Title.Trim(),
|
||||
Priority = upsert.Priority,
|
||||
IsCompleted = upsert.IsCompleted,
|
||||
ParentTaskId = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_dbContext.Tasks.Add(entity);
|
||||
// 并发写入冲突:其他事务已插入相同 Id 的任务
|
||||
// 回滚当前事务,下次循环将重新查询并正确识别已存在实体
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
{
|
||||
return new CloudTaskItem
|
||||
@@ -192,10 +333,15 @@ public class CloudTaskSyncService
|
||||
Title = task.Title,
|
||||
Priority = task.Priority,
|
||||
IsCompleted = task.IsCompleted,
|
||||
CreatedAtUtc = task.CreatedAt,
|
||||
UpdatedAtUtc = task.UpdatedAt,
|
||||
ParentTaskId = task.ParentTaskId
|
||||
Code = task.Code,
|
||||
ParentTaskId = task.ParentTaskId,
|
||||
CreationTime = task.CreationTime,
|
||||
CreatorId = task.CreatorId,
|
||||
LastModificationTime = task.LastModificationTime,
|
||||
LastModifierId = task.LastModifierId,
|
||||
IsDeleted = task.IsDeleted,
|
||||
DeletionTime = task.DeletionTime,
|
||||
DeleterId = task.DeleterId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class TodoDbContext : DbContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务集合。
|
||||
/// 任务集合(ABP 表名规范:T_Tasks)。
|
||||
/// </summary>
|
||||
public DbSet<TaskEntity> Tasks { get; set; }
|
||||
|
||||
@@ -52,17 +52,34 @@ public class TodoDbContext : DbContext
|
||||
|
||||
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
|
||||
|
||||
// TaskEntity 配置(ABP 风格审计字段)
|
||||
modelBuilder.Entity<TaskEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("Tasks");
|
||||
entity.ToTable("T_Tasks");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
// 业务字段
|
||||
entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
|
||||
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
|
||||
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
|
||||
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
|
||||
entity.Property(e => e.CreatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
|
||||
entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
|
||||
entity.Property(e => e.ParentTaskId).HasColumnType("TEXT");
|
||||
|
||||
// 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)
|
||||
.WithMany(u => u.Tasks)
|
||||
.HasForeignKey(e => e.UserId)
|
||||
@@ -127,4 +144,4 @@ public class TodoDbContext : DbContext
|
||||
entity.HasIndex(e => e.TimestampUtc);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,66 +3,66 @@ using Hua.Todo.Application.Models;
|
||||
namespace Hua.Todo.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 任务管理服务接口
|
||||
/// 任务管理服务接口。
|
||||
/// </summary>
|
||||
public interface ITaskService : IDynamicApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取所有任务
|
||||
/// 获取所有任务。
|
||||
/// </summary>
|
||||
/// <returns>包含所有任务的列表</returns>
|
||||
/// <returns>包含所有任务的列表。</returns>
|
||||
Task<List<TaskDto>> GetAllTasksAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取任务
|
||||
/// 根据 ID 获取任务。
|
||||
/// </summary>
|
||||
/// <param name="id">任务唯一标识符</param>
|
||||
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
|
||||
Task<TaskDto?> GetTaskByIdAsync(int id);
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <returns>匹配的任务 DTO,如果未找到则返回 null。</returns>
|
||||
Task<TaskDto?> GetTaskByIdAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// 获取未完成的任务
|
||||
/// 获取未完成的任务。
|
||||
/// </summary>
|
||||
/// <returns>未完成任务的列表</returns>
|
||||
/// <returns>未完成任务的列表。</returns>
|
||||
Task<List<TaskDto>> GetActiveTasksAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 获取已完成的任务
|
||||
/// 获取已完成的任务。
|
||||
/// </summary>
|
||||
/// <returns>已完成任务的列表</returns>
|
||||
/// <returns>已完成任务的列表。</returns>
|
||||
Task<List<TaskDto>> GetCompletedTasksAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 创建新任务
|
||||
/// 创建新任务。
|
||||
/// </summary>
|
||||
/// <param name="dto">创建任务所需的数据传输对象</param>
|
||||
/// <returns>创建成功的任务 DTO</returns>
|
||||
/// <param name="dto">创建任务所需的数据传输对象。</param>
|
||||
/// <returns>创建成功的任务 DTO。</returns>
|
||||
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// 更新任务信息
|
||||
/// 更新任务信息。
|
||||
/// </summary>
|
||||
/// <param name="dto">包含更新信息的任务数据传输对象</param>
|
||||
/// <returns>更新后的任务 DTO</returns>
|
||||
/// <param name="dto">包含更新信息的任务数据传输对象。</param>
|
||||
/// <returns>更新后的任务 DTO。</returns>
|
||||
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// 切换任务完成状态
|
||||
/// 切换任务完成状态。
|
||||
/// </summary>
|
||||
/// <param name="id">任务唯一标识符</param>
|
||||
/// <returns>更新状态后的任务 DTO</returns>
|
||||
Task<TaskDto> ToggleCompleteAsync(int id);
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <returns>更新状态后的任务 DTO。</returns>
|
||||
Task<TaskDto> ToggleCompleteAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// 删除任务
|
||||
/// 删除任务。
|
||||
/// </summary>
|
||||
/// <param name="id">要删除的任务唯一标识符</param>
|
||||
Task DeleteTaskAsync(int id);
|
||||
/// <param name="id">要删除的任务 ID。</param>
|
||||
Task DeleteTaskAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// 获取子任务列表
|
||||
/// 获取子任务列表。
|
||||
/// </summary>
|
||||
/// <param name="parentTaskId">父任务唯一标识符</param>
|
||||
/// <returns>该父任务下的所有子任务列表</returns>
|
||||
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
|
||||
}
|
||||
/// <param name="parentTaskId">父任务 ID。</param>
|
||||
/// <returns>该父任务下的所有子任务列表。</returns>
|
||||
Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId);
|
||||
}
|
||||
+299
@@ -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 -> Guid,CreatedAt -> CreationTime,UpdatedAt -> 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 Hua.Todo.Application.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -102,23 +102,52 @@ namespace Hua.Todo.Application.Migrations
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.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")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int?>("ParentTaskId")
|
||||
.HasColumnType("INTEGER");
|
||||
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()
|
||||
@@ -130,12 +159,6 @@ namespace Hua.Todo.Application.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
@@ -147,7 +170,7 @@ namespace Hua.Todo.Application.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Tasks", (string)null);
|
||||
b.ToTable("T_Tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||
|
||||
@@ -13,16 +13,16 @@ public class CreateTaskDto
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
/// <summary>
|
||||
/// 任务优先级。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
|
||||
|
||||
/// <summary>
|
||||
/// 父任务 ID(用于创建子任务)。
|
||||
/// </summary>
|
||||
public int? ParentTaskId { get; set; }
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,17 +33,17 @@ public class UpdateTaskDto
|
||||
/// <summary>
|
||||
/// 任务 ID。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新标题(可选)。
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
/// <summary>
|
||||
/// 新优先级(可选)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public TaskPriority? Priority { get; set; }
|
||||
}
|
||||
|
||||
@@ -55,34 +55,44 @@ public class TaskDto
|
||||
/// <summary>
|
||||
/// 任务 ID。
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务标题。
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
/// <summary>
|
||||
/// 任务优先级。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public TaskPriority Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已完成。
|
||||
/// </summary>
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务编号(用户级自增字符串)。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreationTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// 最后修改时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime? LastModificationTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父任务 ID(可选)。
|
||||
/// </summary>
|
||||
public int? ParentTaskId { get; set; }
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 子任务列表。
|
||||
/// </summary>
|
||||
@@ -99,16 +109,19 @@ public class ApiResponse<T>
|
||||
/// 是否成功。
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数据(可选)。
|
||||
/// </summary>
|
||||
public T? Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提示信息。
|
||||
/// </summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 错误列表。
|
||||
/// </summary>
|
||||
public List<string> Errors { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,17 @@ public class TaskRepository : ITaskRepository
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有任务。
|
||||
/// 获取所有任务(本地用户),平面加载以支持任意深度树构建。
|
||||
/// 不再使用 Include(t => t.SubTasks) 因为 EF Core 无法递归加载未知深度的子任务;
|
||||
/// 树的构建交由 <see cref="TaskService.GetAllTasksAsync"/> 通过 parentTaskId 在内存中完成。
|
||||
/// </summary>
|
||||
/// <returns>包含所有任务实体的列表。</returns>
|
||||
/// <returns>包含所有任务实体的平面列表。</returns>
|
||||
public async Task<List<TaskEntity>> GetAllAsync()
|
||||
{
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.UserId == TodoUserIds.LocalUserId)
|
||||
.Include(t => t.SubTasks)
|
||||
.OrderByDescending(t => t.CreationTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -38,7 +41,7 @@ public class TaskRepository : ITaskRepository
|
||||
/// </summary>
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
|
||||
public async Task<TaskEntity?> GetByIdAsync(int id)
|
||||
public async Task<TaskEntity?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Include(t => t.SubTasks)
|
||||
@@ -53,7 +56,7 @@ public class TaskRepository : ITaskRepository
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Where(t => t.UserId == TodoUserIds.LocalUserId && !t.IsCompleted)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.OrderByDescending(t => t.CreationTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ public class TaskRepository : ITaskRepository
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.IsCompleted)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.OrderByDescending(t => t.LastModificationTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -77,6 +80,12 @@ public class TaskRepository : ITaskRepository
|
||||
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
|
||||
{
|
||||
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);
|
||||
await _context.SaveChangesAsync();
|
||||
return taskEntity;
|
||||
@@ -89,18 +98,18 @@ public class TaskRepository : ITaskRepository
|
||||
/// <returns>更新后的任务实体。</returns>
|
||||
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
|
||||
{
|
||||
taskEntity.UpdatedAt = DateTime.UtcNow;
|
||||
taskEntity.LastModificationTime = DateTime.UtcNow;
|
||||
taskEntity.LastModifierId = taskEntity.UserId;
|
||||
_context.Tasks.Update(taskEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
return taskEntity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 删除任务。
|
||||
/// 根据 ID 删除任务(物理删除)。
|
||||
/// </summary>
|
||||
/// <param name="id">要删除的任务 ID。</param>
|
||||
/// <returns>表示删除操作的任务。</returns>
|
||||
public async Task DeleteAsync(int id)
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.UserId == TodoUserIds.LocalUserId);
|
||||
if (task != null)
|
||||
@@ -115,11 +124,11 @@ public class TaskRepository : ITaskRepository
|
||||
/// </summary>
|
||||
/// <param name="parentTaskId">父任务 ID。</param>
|
||||
/// <returns>子任务实体的列表。</returns>
|
||||
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
|
||||
public async Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.Where(t => t.UserId == TodoUserIds.LocalUserId && t.ParentTaskId == parentTaskId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.OrderByDescending(t => t.CreationTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,46 +6,69 @@ using Hua.Todo.Core.Interfaces;
|
||||
namespace Hua.Todo.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 任务管理服务实现
|
||||
/// 任务管理服务实现。
|
||||
/// </summary>
|
||||
public class TaskService : ITaskService
|
||||
{
|
||||
private readonly ITaskRepository _taskRepository;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化任务管理服务的新实例
|
||||
/// 初始化任务管理服务的新实例。
|
||||
/// </summary>
|
||||
/// <param name="taskRepository">任务仓储接口</param>
|
||||
/// <param name="taskRepository">任务仓储接口。</param>
|
||||
public TaskService(ITaskRepository taskRepository)
|
||||
{
|
||||
_taskRepository = taskRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有任务
|
||||
/// 获取所有任务(包含任意深度的子树)。
|
||||
/// 先从 DB 平面加载全部任务,再在内存中通过 parentTaskId 构建树结构。
|
||||
/// </summary>
|
||||
/// <returns>所有任务的 DTO 列表</returns>
|
||||
/// <returns>完整的任务树 DTO 列表。</returns>
|
||||
public async Task<List<TaskDto>> GetAllTasksAsync()
|
||||
{
|
||||
var tasks = await _taskRepository.GetAllAsync();
|
||||
return tasks.Select(MapToDto).ToList();
|
||||
var allTasks = await _taskRepository.GetAllAsync();
|
||||
|
||||
// 构建 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>
|
||||
/// 根据 ID 获取任务详情
|
||||
/// 根据 ID 获取任务详情。
|
||||
/// </summary>
|
||||
/// <param name="id">任务 ID</param>
|
||||
/// <returns>找到的任务 DTO,如果不存在则返回 null</returns>
|
||||
public async Task<TaskDto?> GetTaskByIdAsync(int id)
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <returns>找到的任务 DTO,如果不存在则返回 null。</returns>
|
||||
public async Task<TaskDto?> GetTaskByIdAsync(Guid id)
|
||||
{
|
||||
var task = await _taskRepository.GetByIdAsync(id);
|
||||
return task != null ? MapToDto(task) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有未完成的任务
|
||||
/// 获取所有未完成的任务。
|
||||
/// </summary>
|
||||
/// <returns>未完成任务的 DTO 列表</returns>
|
||||
/// <returns>未完成任务的 DTO 列表。</returns>
|
||||
public async Task<List<TaskDto>> GetActiveTasksAsync()
|
||||
{
|
||||
var allTasks = await _taskRepository.GetAllAsync();
|
||||
@@ -53,9 +76,9 @@ public class TaskService : ITaskService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有已完成的任务
|
||||
/// 获取所有已完成的任务。
|
||||
/// </summary>
|
||||
/// <returns>已完成任务的 DTO 列表</returns>
|
||||
/// <returns>已完成任务的 DTO 列表。</returns>
|
||||
public async Task<List<TaskDto>> GetCompletedTasksAsync()
|
||||
{
|
||||
var allTasks = await _taskRepository.GetAllAsync();
|
||||
@@ -63,19 +86,24 @@ public class TaskService : ITaskService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建新任务
|
||||
/// 创建新任务。
|
||||
/// </summary>
|
||||
/// <param name="dto">任务创建 DTO</param>
|
||||
/// <returns>新创建的任务 DTO</returns>
|
||||
/// <param name="dto">任务创建 DTO。</param>
|
||||
/// <returns>新创建的任务 DTO。</returns>
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = dto.Title,
|
||||
Priority = dto.Priority,
|
||||
IsCompleted = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
Code = (maxCode + 1).ToString(),
|
||||
ParentTaskId = dto.ParentTaskId
|
||||
};
|
||||
|
||||
@@ -84,11 +112,11 @@ public class TaskService : ITaskService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新现有任务
|
||||
/// 更新现有任务。
|
||||
/// </summary>
|
||||
/// <param name="dto">包含更新内容的 DTO</param>
|
||||
/// <returns>更新后的任务 DTO</returns>
|
||||
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
|
||||
/// <param name="dto">包含更新内容的 DTO。</param>
|
||||
/// <returns>更新后的任务 DTO。</returns>
|
||||
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出。</exception>
|
||||
public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
|
||||
{
|
||||
var task = await _taskRepository.GetByIdAsync(dto.Id);
|
||||
@@ -107,19 +135,17 @@ public class TaskService : ITaskService
|
||||
task.Priority = dto.Priority.Value;
|
||||
}
|
||||
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var updatedTask = await _taskRepository.UpdateAsync(task);
|
||||
return MapToDto(updatedTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 切换任务的完成状态
|
||||
/// 切换任务的完成状态。
|
||||
/// </summary>
|
||||
/// <param name="id">任务 ID</param>
|
||||
/// <returns>状态切换后的任务 DTO</returns>
|
||||
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
|
||||
public async Task<TaskDto> ToggleCompleteAsync(int id)
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <returns>状态切换后的任务 DTO。</returns>
|
||||
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出。</exception>
|
||||
public async Task<TaskDto> ToggleCompleteAsync(Guid id)
|
||||
{
|
||||
var task = await _taskRepository.GetByIdAsync(id);
|
||||
if (task == null)
|
||||
@@ -128,18 +154,17 @@ public class TaskService : ITaskService
|
||||
}
|
||||
|
||||
task.IsCompleted = !task.IsCompleted;
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var updatedTask = await _taskRepository.UpdateAsync(task);
|
||||
return MapToDto(updatedTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除指定 ID 的任务
|
||||
/// 删除指定 ID 的任务。
|
||||
/// </summary>
|
||||
/// <param name="id">任务 ID</param>
|
||||
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出</exception>
|
||||
public async Task DeleteTaskAsync(int id)
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <exception cref="KeyNotFoundException">当任务 ID 不存在时抛出。</exception>
|
||||
public async Task DeleteTaskAsync(Guid id)
|
||||
{
|
||||
var task = await _taskRepository.GetByIdAsync(id);
|
||||
if (task == null)
|
||||
@@ -151,20 +176,21 @@ public class TaskService : ITaskService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定父任务的所有子任务
|
||||
/// 获取指定父任务的所有子任务。
|
||||
/// </summary>
|
||||
/// <param name="parentTaskId">父任务 ID</param>
|
||||
/// <returns>子任务的 DTO 列表</returns>
|
||||
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId)
|
||||
/// <param name="parentTaskId">父任务 ID。</param>
|
||||
/// <returns>子任务的 DTO 列表。</returns>
|
||||
public async Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
|
||||
{
|
||||
var allTasks = await _taskRepository.GetAllAsync();
|
||||
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 实体转 DTO 映射方法
|
||||
/// 实体转 DTO 映射方法。
|
||||
/// 注意:SubTasks 默认为空列表,由 <see cref="GetAllTasksAsync"/> 在内存中通过 parentTaskId 构建树。
|
||||
/// </summary>
|
||||
private TaskDto MapToDto(TaskEntity task)
|
||||
private static TaskDto MapToDto(TaskEntity task)
|
||||
{
|
||||
return new TaskDto
|
||||
{
|
||||
@@ -172,10 +198,11 @@ public class TaskService : ITaskService
|
||||
Title = task.Title,
|
||||
Priority = task.Priority,
|
||||
IsCompleted = task.IsCompleted,
|
||||
CreatedAt = task.CreatedAt,
|
||||
UpdatedAt = task.UpdatedAt,
|
||||
Code = task.Code,
|
||||
CreationTime = task.CreationTime,
|
||||
LastModificationTime = task.LastModificationTime,
|
||||
ParentTaskId = task.ParentTaskId,
|
||||
SubTasks = task.SubTasks.Select(MapToDto).ToList()
|
||||
SubTasks = new List<TaskDto>()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ public class WebServerSettings
|
||||
|
||||
[JsonPropertyName("ForEndUrl")]
|
||||
public string ForEndUrl { get; set; } = "http://localhost:5174";
|
||||
|
||||
/// <summary>
|
||||
/// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。
|
||||
/// 空字符串表示未配置云同步。
|
||||
/// </summary>
|
||||
[JsonPropertyName("CloudSyncUrl")]
|
||||
public string CloudSyncUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
public class HotKeyDefaultSettings
|
||||
|
||||
@@ -11,6 +11,7 @@ using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Hua.Todo.Application;
|
||||
using Hua.Todo.Application.CloudSync;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Hua.Todo.Application.DynamicApi;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
@@ -68,6 +69,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
// 注册应用逻辑服务
|
||||
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 =>
|
||||
{
|
||||
@@ -81,6 +89,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
|
||||
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);
|
||||
|
||||
// 如果配置为使用静态文件(前端托管),则配置静态文件服务
|
||||
@@ -90,9 +105,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
}
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权)
|
||||
app.UseAuthorization();
|
||||
app.UseDynamicApi();
|
||||
app.MapControllers();
|
||||
app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings
|
||||
|
||||
_webApp = app;
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"IsUsingStatic": true,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5057"
|
||||
"ForEndUrl": "http://localhost:5057",
|
||||
"CloudSyncUrl": ""
|
||||
},
|
||||
"HotKey": {
|
||||
"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; }
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
namespace Hua.Todo.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 任务实体类,表示一个代办
|
||||
/// 任务实体类,表示一个 Todo 待办项。
|
||||
/// 继承 <see cref="FullAuditedEntityWithUser{Guid, UserEntity}"/> 以获得 ABP 风格的审计字段。
|
||||
/// </summary>
|
||||
public class TaskEntity
|
||||
public class TaskEntity : FullAuditedEntityWithUser<Guid, UserEntity>
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务所属用户 ID。
|
||||
/// 本地模式下使用固定的“本地用户”ID,以确保本地任务与云端多用户任务隔离。
|
||||
/// 任务所属用户 ID(云端隔离),本地模式为 <see cref="TodoUserIds.LocalUserId"/>。
|
||||
/// </summary>
|
||||
public Guid UserId { get; set; } = TodoUserIds.LocalUserId;
|
||||
|
||||
@@ -17,47 +17,38 @@ public class TaskEntity
|
||||
public UserEntity? User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务唯一标识符
|
||||
/// </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 string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 任务是否已完成。
|
||||
/// </summary>
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 任务创建时间(UTC)
|
||||
/// 父任务 ID(外键),用于支持子任务功能。
|
||||
/// 类型为 <see cref="Guid"/>,与主键类型一致。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务最后更新时间(UTC)
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 父任务ID,用于支持子任务功能
|
||||
/// </summary>
|
||||
public int? ParentTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父任务导航属性
|
||||
/// 父任务导航属性。
|
||||
/// </summary>
|
||||
public TaskEntity? ParentTask { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 子任务集合
|
||||
/// 子任务集合。
|
||||
/// </summary>
|
||||
public List<TaskEntity> SubTasks { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ namespace Hua.Todo.Core.Entities;
|
||||
/// <summary>
|
||||
/// 用户实体,用于云同步场景下的用户隔离与权限控制。
|
||||
/// </summary>
|
||||
public class UserEntity
|
||||
public class UserEntity : IUser<Guid>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户唯一标识符。
|
||||
|
||||
@@ -3,60 +3,59 @@ using Hua.Todo.Core.Entities;
|
||||
namespace Hua.Todo.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 任务仓储接口,定义任务数据访问操作
|
||||
/// 任务仓储接口,定义任务数据访问操作。
|
||||
/// </summary>
|
||||
public interface ITaskRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取所有任务
|
||||
/// 获取所有任务。
|
||||
/// </summary>
|
||||
/// <returns>包含所有任务实体的列表任务</returns>
|
||||
/// <returns>包含所有任务实体的列表。</returns>
|
||||
System.Threading.Tasks.Task<List<TaskEntity>> GetAllAsync();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 根据ID获取指定任务
|
||||
/// 根据 ID 获取任务。
|
||||
/// </summary>
|
||||
/// <param name="id">任务唯一标识符</param>
|
||||
/// <returns>任务实体对象,如果不存在则返回 null 的任务</returns>
|
||||
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(int id);
|
||||
|
||||
/// <param name="id">任务 ID。</param>
|
||||
/// <returns>匹配的任务实体;如果不存在则返回 null。</returns>
|
||||
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有未完成的任务
|
||||
/// 获取未完成任务列表。
|
||||
/// </summary>
|
||||
/// <returns>未完成任务实体的列表任务</returns>
|
||||
/// <returns>未完成的任务实体列表。</returns>
|
||||
System.Threading.Tasks.Task<List<TaskEntity>> GetActiveTasksAsync();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有已完成的任务
|
||||
/// 获取已完成任务列表。
|
||||
/// </summary>
|
||||
/// <returns>已完成任务实体的列表任务</returns>
|
||||
/// <returns>已完成的任务实体列表。</returns>
|
||||
System.Threading.Tasks.Task<List<TaskEntity>> GetCompletedTasksAsync();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 添加新任务
|
||||
/// 新增一个任务。
|
||||
/// </summary>
|
||||
/// <param name="taskEntity">要添加的任务实体对象</param>
|
||||
/// <returns>添加后的任务实体(包含生成的 ID)的任务</returns>
|
||||
/// <param name="taskEntity">要添加的任务实体。</param>
|
||||
/// <returns>已持久化的任务实体(包含生成的 ID)。</returns>
|
||||
System.Threading.Tasks.Task<TaskEntity> AddAsync(TaskEntity taskEntity);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 更新任务
|
||||
/// 更新现有任务信息。
|
||||
/// </summary>
|
||||
/// <param name="taskEntity">要更新的任务实体对象</param>
|
||||
/// <returns>更新后的任务实体对象的任务</returns>
|
||||
/// <param name="taskEntity">要更新的任务实体。</param>
|
||||
/// <returns>更新后的任务实体。</returns>
|
||||
System.Threading.Tasks.Task<TaskEntity> UpdateAsync(TaskEntity taskEntity);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 删除指定ID的任务
|
||||
/// 根据 ID 删除任务(物理删除)。
|
||||
/// </summary>
|
||||
/// <param name="id">要删除的任务唯一标识符</param>
|
||||
/// <returns>表示删除操作的任务</returns>
|
||||
System.Threading.Tasks.Task DeleteAsync(int id);
|
||||
|
||||
/// <param name="id">要删除的任务 ID。</param>
|
||||
System.Threading.Tasks.Task DeleteAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定父任务的所有子任务
|
||||
/// 获取指定父任务的子任务列表。
|
||||
/// </summary>
|
||||
/// <param name="parentTaskId">父任务唯一标识符</param>
|
||||
/// <returns>子任务实体的列表任务</returns>
|
||||
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId);
|
||||
}
|
||||
/// <param name="parentTaskId">父任务 ID。</param>
|
||||
/// <returns>子任务实体的列表。</returns>
|
||||
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(Guid parentTaskId);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Hua.Todo.Application;
|
||||
using Hua.Todo.Application.CloudSync;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Hua.Todo.Maui.Models;
|
||||
using Hua.Todo.Maui.Services;
|
||||
|
||||
@@ -29,6 +29,13 @@ public class WebServerSettings
|
||||
|
||||
[JsonPropertyName("ForEndUrl")]
|
||||
public string ForEndUrl { get; set; } = "http://localhost:5174";
|
||||
|
||||
/// <summary>
|
||||
/// 云同步服务端地址(如 http://localhost:5173),由前端设置弹窗写入后持久化。
|
||||
/// 空字符串表示未配置云同步。
|
||||
/// </summary>
|
||||
[JsonPropertyName("CloudSyncUrl")]
|
||||
public string CloudSyncUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
public class HotKeyDefaultSettings
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Text.Json;
|
||||
using Hua.Todo.Application;
|
||||
using Hua.Todo.Application.CloudSync;
|
||||
using Hua.Todo.Application.DynamicApi;
|
||||
using Hua.Todo.Application.DynamicApi.Swagger;
|
||||
using Hua.Todo.Maui.Models;
|
||||
@@ -76,6 +77,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
// 注册应用逻辑服务
|
||||
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 =>
|
||||
{
|
||||
@@ -89,6 +97,13 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
|
||||
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
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
@@ -101,9 +116,11 @@ public class EmbeddedWebServerService : IEmbeddedWebServerService
|
||||
}
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
app.UseCloudSyncProxy(); // 云同步反向代理(在授权之前,由远程 Host 做鉴权)
|
||||
app.UseAuthorization();
|
||||
app.UseDynamicApi();
|
||||
app.MapControllers();
|
||||
app.MapCloudSyncProxySettings(); // GET/POST /api/cloud-sync/settings
|
||||
|
||||
_webApp = app;
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"IsUsingStatic": false,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5174"
|
||||
"ForEndUrl": "http://localhost:5174",
|
||||
"CloudSyncUrl": ""
|
||||
},
|
||||
"Development": {
|
||||
},
|
||||
|
||||
@@ -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:同一批次中同步父子任务(均有已知 Id,lastModificationTime 为 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
# MAUI 模式:API 与云同步共用本地后端代理目标
|
||||
# MAUI 模式:所有请求代理到 MAUI 嵌入式 WebServer
|
||||
# 云同步请求由嵌入式服务器的 CloudSyncProxyMiddleware 转发到远程 Host
|
||||
VITE_API_TARGET=http://localhost:5057
|
||||
@@ -32,17 +32,10 @@ const cloudClient = axios.create({
|
||||
});
|
||||
|
||||
cloudClient.interceptors.request.use((config) => {
|
||||
// 根据运行模式决定 baseURL:
|
||||
// - Host 模式(Vite dev):使用同源请求,通过 Vite proxy 到 Host
|
||||
// - MAUI 模式:使用配置的 serverUrl 连接外部云同步服务器
|
||||
const isMaui = (window as any).__IS_MAUI__ === true;
|
||||
if (isMaui) {
|
||||
const { serverUrl } = CloudSyncStorage.loadSettings();
|
||||
if (serverUrl) {
|
||||
config.baseURL = serverUrl;
|
||||
}
|
||||
}
|
||||
// Host 模式下不设置 baseURL,axios 使用浏览器默认同源
|
||||
// MAUI 模式:使用同源请求,由嵌入式 WebServer 的云同步代理中间件转发到远程 Host。
|
||||
// 不再从 localStorage 读取 serverUrl —— 地址由嵌入式服务器的 CloudSyncProxySettings 管理。
|
||||
// Host 模式(Vite dev):也使用同源请求,通过 Vite proxy 转发到 Host。
|
||||
// 两种模式均不设置 baseURL,axios 使用浏览器默认同源。
|
||||
|
||||
const session = CloudSyncStorage.loadSession();
|
||||
if (session?.accessToken) {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import cloudClient from './cloudClient';
|
||||
import CloudSyncStorage from '../services/cloudSyncStorage';
|
||||
import type { Task } from '../types/task';
|
||||
import type { Task, TaskPriority } from '../types/task';
|
||||
|
||||
/**
|
||||
* 登录请求。
|
||||
*/
|
||||
export interface CloudLoginRequest {
|
||||
userName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录响应。
|
||||
*/
|
||||
export interface CloudLoginResponse {
|
||||
accessToken: string;
|
||||
expiresAtUtc: string;
|
||||
@@ -15,14 +21,27 @@ export interface CloudLoginResponse {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 云同步任务条目(与 CloudTaskItem DTO 对应)。
|
||||
* - id 为 Guid 序列化后的字符串
|
||||
* - 包含 ABP 审计字段
|
||||
*/
|
||||
export interface CloudTaskItem {
|
||||
id: number;
|
||||
id: string;
|
||||
title: string;
|
||||
priority: 0 | 1 | 2;
|
||||
priority: TaskPriority;
|
||||
isCompleted: boolean;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
parentTaskId?: number | null;
|
||||
code: string;
|
||||
parentTaskId: string | 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。
|
||||
*/
|
||||
export interface CloudTaskUpsert {
|
||||
id?: number | null;
|
||||
id?: string | null;
|
||||
title: string;
|
||||
priority: 0 | 1 | 2;
|
||||
priority: TaskPriority;
|
||||
isCompleted: boolean;
|
||||
parentTaskId?: number | null;
|
||||
code: string;
|
||||
parentTaskId?: string | null;
|
||||
lastModificationTime?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +63,7 @@ export interface CloudTaskUpsert {
|
||||
*/
|
||||
export interface CloudSyncRequest {
|
||||
upserts: CloudTaskUpsert[];
|
||||
deletes: number[];
|
||||
deletes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +74,9 @@ export interface CloudSyncResponse {
|
||||
tasks: CloudTaskItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全策略响应。
|
||||
*/
|
||||
export interface SecurityPolicyResponse {
|
||||
allowPersist: boolean;
|
||||
allowSync: boolean;
|
||||
@@ -74,16 +98,22 @@ export interface ProbeServerUrlResponse {
|
||||
* 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。
|
||||
*/
|
||||
export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
|
||||
const map = new Map<number, Task>();
|
||||
const map = new Map<string, Task>();
|
||||
for (const item of items) {
|
||||
map.set(item.id, {
|
||||
id: item.id,
|
||||
title: item.title ?? '',
|
||||
priority: item.priority ?? 1,
|
||||
isCompleted: Boolean(item.isCompleted),
|
||||
createdAt: item.createdAtUtc,
|
||||
updatedAt: item.updatedAtUtc,
|
||||
parentTaskId: item.parentTaskId ?? undefined,
|
||||
code: item.code ?? '',
|
||||
parentTaskId: item.parentTaskId,
|
||||
creationTime: item.creationTime,
|
||||
creatorId: item.creatorId,
|
||||
lastModificationTime: item.lastModificationTime,
|
||||
lastModifierId: item.lastModifierId,
|
||||
isDeleted: item.isDeleted,
|
||||
deletionTime: item.deletionTime,
|
||||
deleterId: item.deleterId,
|
||||
subTasks: [],
|
||||
});
|
||||
}
|
||||
@@ -91,7 +121,7 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
|
||||
const roots: Task[] = [];
|
||||
for (const task of map.values()) {
|
||||
const parentId = task.parentTaskId;
|
||||
if (typeof parentId === 'number' && map.has(parentId)) {
|
||||
if (parentId != null && map.has(parentId)) {
|
||||
map.get(parentId)!.subTasks.push(task);
|
||||
} else {
|
||||
roots.push(task);
|
||||
@@ -103,25 +133,29 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => {
|
||||
|
||||
/**
|
||||
* 把前端的任务树结构扁平化为 CloudTaskUpsert 列表,用于发送到云端。
|
||||
* - id > 0 表示已同步到云端的任务(保留 id 用于更新)
|
||||
* - id <= 0 或未设置表示本地新建任务(id 置为 null,由服务端分配新 ID)
|
||||
* - 使用 seen 集合去重:同一任务(按 id 或 hash)只发送一次
|
||||
* - id 存在且有效(Guid 格式)表示已同步到云端的任务(保留 id 用于更新)
|
||||
* - id 为空或无效表示本地新建任务(id 置为 null/undefined,由服务端分配新 ID)
|
||||
* - 使用 seen 集合去重:同一任务(按 id)只发送一次
|
||||
*/
|
||||
export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => {
|
||||
const result: CloudTaskUpsert[] = [];
|
||||
const seenIds = new Set<number | null>();
|
||||
const seenIds = new Set<string | null>();
|
||||
const walk = (items: Task[]) => {
|
||||
for (const task of items) {
|
||||
const id = task.id > 0 ? task.id : null;
|
||||
// 有效 Guid 字符串作为已同步任务的标识,否则视为新建任务
|
||||
const id = isValidGuid(task.id) ? task.id : null;
|
||||
// 去重:同一 id 只保留第一次出现
|
||||
if (id != null && seenIds.has(id)) continue;
|
||||
if (id != null) seenIds.add(id);
|
||||
result.push({
|
||||
id,
|
||||
title: task.title,
|
||||
priority: task.priority as 0 | 1 | 2,
|
||||
priority: task.priority as TaskPriority,
|
||||
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) {
|
||||
walk(task.subTasks);
|
||||
@@ -132,6 +166,13 @@ export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] =>
|
||||
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 最小闭环:登录 + 拉取任务)。
|
||||
*/
|
||||
@@ -180,7 +221,7 @@ export const cloudSyncApi = {
|
||||
* 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。
|
||||
* 注意:该端点需要已登录 + 二次认证(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 body: CloudSyncRequest = { upserts, deletes: deleteIds };
|
||||
const response = await cloudClient.post<CloudSyncResponse>('/sync/', body);
|
||||
|
||||
@@ -2,6 +2,7 @@ import apiClient from './client';
|
||||
import type { Task, CreateTaskDto, UpdateTaskDto, ApiResponse } from '../types/task';
|
||||
import LocalStorageService from '../services/localStorageService';
|
||||
import { normalizeTask, normalizeTasks } from '../services/taskNormalizer';
|
||||
import { generateGuid } from '../utils/guid';
|
||||
|
||||
export const taskApi = {
|
||||
/**
|
||||
@@ -63,7 +64,7 @@ export const taskApi = {
|
||||
* @param id 任务 ID
|
||||
* @returns 包含任务对象或 null 的响应对象
|
||||
*/
|
||||
async getTask(id: number): Promise<ApiResponse<Task | null>> {
|
||||
async getTask(id: string): Promise<ApiResponse<Task | null>> {
|
||||
if (!LocalStorageService.isOnline()) {
|
||||
const localTasks = LocalStorageService.loadTasks();
|
||||
const task = localTasks.find(t => t.id === id);
|
||||
@@ -102,19 +103,31 @@ export const taskApi = {
|
||||
* @returns 包含新创建任务的响应对象
|
||||
*/
|
||||
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 = {
|
||||
id: LocalStorageService.getNextTaskId(),
|
||||
id: generateGuid(),
|
||||
title: dto.title,
|
||||
priority: dto.priority,
|
||||
isCompleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
parentTaskId: dto.parentTaskId,
|
||||
code: String(maxCode + 1),
|
||||
creationTime: now,
|
||||
creatorId: null,
|
||||
lastModificationTime: now,
|
||||
lastModifierId: null,
|
||||
isDeleted: false,
|
||||
deletionTime: null,
|
||||
deleterId: null,
|
||||
parentTaskId: dto.parentTaskId ?? null,
|
||||
subTasks: []
|
||||
};
|
||||
|
||||
if (!LocalStorageService.isOnline()) {
|
||||
const localTasks = LocalStorageService.loadTasks();
|
||||
localTasks.unshift(newTask);
|
||||
LocalStorageService.saveTasks(localTasks);
|
||||
|
||||
@@ -167,13 +180,13 @@ export const taskApi = {
|
||||
* @param dto 更新任务的数据传输对象
|
||||
* @returns 包含更新后任务的响应对象
|
||||
*/
|
||||
async updateTask(id: number, dto: UpdateTaskDto): Promise<ApiResponse<Task>> {
|
||||
async updateTask(id: string, dto: UpdateTaskDto): Promise<ApiResponse<Task>> {
|
||||
const updateDto = dto;
|
||||
|
||||
|
||||
if (!LocalStorageService.isOnline()) {
|
||||
const localTasks = LocalStorageService.loadTasks();
|
||||
const taskIndex = localTasks.findIndex(t => t.id === id);
|
||||
|
||||
|
||||
if (taskIndex !== -1) {
|
||||
if (dto.title) {
|
||||
localTasks[taskIndex].title = dto.title;
|
||||
@@ -181,7 +194,7 @@ export const taskApi = {
|
||||
if (dto.priority !== undefined) {
|
||||
localTasks[taskIndex].priority = dto.priority;
|
||||
}
|
||||
localTasks[taskIndex].updatedAt = new Date().toISOString();
|
||||
localTasks[taskIndex].lastModificationTime = new Date().toISOString();
|
||||
LocalStorageService.saveTasks(localTasks);
|
||||
|
||||
const syncStatus = LocalStorageService.loadSyncStatus();
|
||||
@@ -234,14 +247,14 @@ export const taskApi = {
|
||||
* @param id 任务 ID
|
||||
* @returns 包含更新后任务的响应对象
|
||||
*/
|
||||
async toggleComplete(id: number): Promise<ApiResponse<Task>> {
|
||||
async toggleComplete(id: string): Promise<ApiResponse<Task>> {
|
||||
if (!LocalStorageService.isOnline()) {
|
||||
const localTasks = LocalStorageService.loadTasks();
|
||||
const taskIndex = localTasks.findIndex(t => t.id === id);
|
||||
|
||||
if (taskIndex !== -1) {
|
||||
localTasks[taskIndex].isCompleted = !localTasks[taskIndex].isCompleted;
|
||||
localTasks[taskIndex].updatedAt = new Date().toISOString();
|
||||
localTasks[taskIndex].lastModificationTime = new Date().toISOString();
|
||||
LocalStorageService.saveTasks(localTasks);
|
||||
|
||||
const syncStatus = LocalStorageService.loadSyncStatus();
|
||||
@@ -294,7 +307,7 @@ export const taskApi = {
|
||||
* @param id 任务 ID
|
||||
* @returns 包含操作结果的响应对象
|
||||
*/
|
||||
async deleteTask(id: number): Promise<ApiResponse<object>> {
|
||||
async deleteTask(id: string): Promise<ApiResponse<object>> {
|
||||
if (!LocalStorageService.isOnline()) {
|
||||
const localTasks = LocalStorageService.loadTasks();
|
||||
const taskIndex = localTasks.findIndex(t => t.id === id);
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
* 通过 CustomEvent 与其他组件通信(cloudSyncStateChanged、cloudSyncPullTasksRequested)。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import CloudSyncStorage from '../services/cloudSyncStorage';
|
||||
import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync';
|
||||
import LocalStorageService, { type SyncStatus } from '../services/localStorageService';
|
||||
@@ -216,17 +217,30 @@ const canSync = computed(() => {
|
||||
// ========== 核心方法 ==========
|
||||
|
||||
/**
|
||||
* 从本地存储加载设置状态
|
||||
* 并在已登录时自动获取安全策略和同步状态
|
||||
* 从本地 API 加载云同步服务端地址。
|
||||
* 启用状态仍从 localStorage 读取(客户端偏好)。
|
||||
* 并在已登录时自动获取安全策略和同步状态。
|
||||
*/
|
||||
const loadLocalState = () => {
|
||||
const loadLocalState = async () => {
|
||||
refreshSession();
|
||||
const settings = CloudSyncStorage.loadSettings();
|
||||
localEnabled.value = settings.enabled;
|
||||
serverUrlSaved.value = settings.serverUrl;
|
||||
serverUrlInput.value = settings.serverUrl;
|
||||
|
||||
refreshSyncStatus(); // 加载同步状态
|
||||
|
||||
// 从嵌入式 WebServer 的本地 API 获取服务端地址
|
||||
try {
|
||||
const res = await axios.get('/api/cloud-sync/settings');
|
||||
const serverUrl = (res.data as any)?.serverUrl ?? '';
|
||||
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) {
|
||||
refreshPolicy();
|
||||
}
|
||||
@@ -367,7 +381,7 @@ const emitCloudSyncStateChanged = () => {
|
||||
const event = new CustomEvent('cloudSyncStateChanged', {
|
||||
detail: {
|
||||
enabled: settings.enabled,
|
||||
serverUrl: settings.serverUrl,
|
||||
serverUrl: serverUrlSaved.value || settings.serverUrl,
|
||||
isLoggedIn: Boolean(session?.accessToken),
|
||||
},
|
||||
});
|
||||
@@ -410,10 +424,17 @@ const saveServerUrl = async () => {
|
||||
}
|
||||
|
||||
serverUrlSaved.value = parsed.normalized;
|
||||
CloudSyncStorage.saveSettings({
|
||||
serverUrl: serverUrlSaved.value,
|
||||
enabled: localEnabled.value,
|
||||
});
|
||||
|
||||
// 写入嵌入式 WebServer 的本地 API(持久化到 appsettings.json)
|
||||
try {
|
||||
await axios.post('/api/cloud-sync/settings', { serverUrl: serverUrlSaved.value });
|
||||
} catch {
|
||||
// 回退到 localStorage
|
||||
CloudSyncStorage.saveSettings({
|
||||
serverUrl: serverUrlSaved.value,
|
||||
enabled: localEnabled.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.warn) {
|
||||
showToast(parsed.warn, 'success');
|
||||
@@ -423,7 +444,7 @@ const saveServerUrl = async () => {
|
||||
|
||||
isProbing.value = true;
|
||||
try {
|
||||
// 调用 Host 的探测接口,由服务端发起探测(避免浏览器直连的 CORS/证书/网络限制)
|
||||
// 探测走同源代理(cloudSyncApi 通过 cloudClient → 同源 → 代理中间件 → 远程 Host)
|
||||
const probeResponse = await cloudSyncApi.probeServerUrl(serverUrlSaved.value);
|
||||
probeResult.value = {
|
||||
type: probeResponse.type as ProbeType,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="task-info">
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<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-created">创建{{ createdAtText }}</span>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated', task: Task): void;
|
||||
(e: 'deleted', id: number): void;
|
||||
(e: 'deleted', id: string): void;
|
||||
(e: 'subtask-created', task: Task): void;
|
||||
}>();
|
||||
|
||||
@@ -145,7 +145,7 @@ const formatDateTime = (value: string): string => {
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
|
||||
};
|
||||
|
||||
const createdAtText = computed(() => formatDateTime(props.task.createdAt));
|
||||
const createdAtText = computed(() => formatDateTime(props.task.creationTime));
|
||||
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
@@ -207,7 +207,7 @@ const handleSubTaskUpdated = (updatedTask: Task) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubTaskDeleted = (subTaskId: number) => {
|
||||
const handleSubTaskDeleted = (subTaskId: string) => {
|
||||
if (props.task.subTasks) {
|
||||
props.task.subTasks = props.task.subTasks.filter(t => t.id !== subTaskId);
|
||||
emit('updated', props.task);
|
||||
|
||||
@@ -286,21 +286,21 @@ const sortTasks = (tasks: Task[]): Task[] => {
|
||||
const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
comparison = priorityDiff;
|
||||
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;
|
||||
case 'completedAt': {
|
||||
const aVal = a.isCompleted ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bVal = b.isCompleted ? new Date(b.updatedAt).getTime() : 0;
|
||||
const aVal = a.isCompleted && a.lastModificationTime ? new Date(a.lastModificationTime).getTime() : 0;
|
||||
const bVal = b.isCompleted && b.lastModificationTime ? new Date(b.lastModificationTime).getTime() : 0;
|
||||
comparison = bVal - aVal;
|
||||
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;
|
||||
}
|
||||
case 'createdAt':
|
||||
default:
|
||||
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
comparison = new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -414,12 +414,12 @@ const updateTaskInTree = (tasks: Task[], updatedTask: Task): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTaskDeleted = (taskId: number) => {
|
||||
const handleTaskDeleted = (taskId: string) => {
|
||||
deleteTaskFromTree(tasks.value, taskId);
|
||||
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++) {
|
||||
if (tasks[i].id === taskId) {
|
||||
tasks.splice(i, 1);
|
||||
@@ -486,7 +486,10 @@ const handleCloudSyncStateChanged = () => {
|
||||
|
||||
/**
|
||||
* 立即同步:将本地任务推送到云端并获取云端最新数据。
|
||||
* 通过 cloudSyncApi.syncTasks 调用 POST /sync/:推送 upserts 列表,云端返回最终状态。
|
||||
* 策略:服务端为单一真相源,但合并而非盲目替换 ——
|
||||
* 1. 上传所有本地任务(upserts)
|
||||
* 2. 服务端按 LWW 合并并返回全量
|
||||
* 3. 以服务端列表为准,同时保留服务端未返回的本地任务(防止数据丢失)
|
||||
*/
|
||||
const syncNow = async () => {
|
||||
if (isSyncing.value) return;
|
||||
@@ -495,9 +498,61 @@ const syncNow = async () => {
|
||||
const localTasks = tasks.value.length > 0
|
||||
? tasks.value
|
||||
: 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);
|
||||
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 serverTimestamp = serverTimeUtc ? new Date(serverTimeUtc).getTime() : Date.now();
|
||||
syncStatus.lastSyncTime = serverTimestamp;
|
||||
@@ -506,7 +561,7 @@ const syncNow = async () => {
|
||||
LocalStorageService.saveSyncStatus(syncStatus);
|
||||
lastSyncTime.value = syncStatus.lastSyncTime;
|
||||
pendingChanges.value = 0;
|
||||
LocalStorageService.syncTaskIdCounter(cloudTasks);
|
||||
LocalStorageService.syncTaskIdCounter(mergedTasks);
|
||||
showToast('同步成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
|
||||
@@ -26,25 +26,24 @@ const toTaskPriority = (value: unknown): TaskPriority => {
|
||||
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 => {
|
||||
const normalized: any = { ...(task ?? {}) };
|
||||
|
||||
if (typeof normalized.id === 'string' && /^\d+$/.test(normalized.id)) {
|
||||
normalized.id = Number(normalized.id);
|
||||
}
|
||||
// id 现在是 Guid 字符串,不再进行类型转换
|
||||
// 保留原始 id 即可(可能是 string 或 number,取决于数据来源)
|
||||
|
||||
normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? '');
|
||||
normalized.priority = toTaskPriority(normalized.priority);
|
||||
normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted);
|
||||
normalized.createdAt = toIsoString(normalized.createdAt);
|
||||
normalized.updatedAt = toIsoString(normalized.updatedAt);
|
||||
normalized.code = typeof normalized.code === 'string' ? normalized.code : '';
|
||||
|
||||
// 统一字段名: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) {
|
||||
normalized.parentTaskId = undefined;
|
||||
|
||||
@@ -1,26 +1,65 @@
|
||||
export type TaskPriority = 0 | 1 | 2;
|
||||
|
||||
/**
|
||||
* Todo 待办项(与 CloudTaskItem DTO 一一对应)。
|
||||
* - 主键 id 为 Guid 序列化后的字符串
|
||||
* - 包含 ABP 审计字段,用于云同步冲突判断
|
||||
*/
|
||||
export interface Task {
|
||||
id: number;
|
||||
// === 业务字段 ===
|
||||
/** 主键(Guid 序列化) */
|
||||
id: string;
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 优先级 */
|
||||
priority: TaskPriority;
|
||||
/** 是否完成 */
|
||||
isCompleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
parentTaskId?: number;
|
||||
/** 任务编号(由后端自动分配,用户级自增字符串) */
|
||||
code: string;
|
||||
/** 父任务 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Todo 待办项请求(客户端 → 服务端,新建时不带 id)
|
||||
*/
|
||||
export interface CreateTaskDto {
|
||||
title: string;
|
||||
priority: TaskPriority;
|
||||
parentTaskId?: number;
|
||||
/** 父任务 ID(Guid 字符串) */
|
||||
parentTaskId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Todo 待办项请求(客户端 → 服务端,必须带 id)
|
||||
*/
|
||||
export interface UpdateTaskDto {
|
||||
id: number;
|
||||
/** 主键(Guid 字符串) */
|
||||
id: string;
|
||||
title?: string;
|
||||
priority?: TaskPriority;
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user