From a7ba814833a33d73c2020fa25de0f16fcc5e4c1f Mon Sep 17 00:00:00 2001 From: ShaoHua <345265198@qqcom> Date: Fri, 12 Jun 2026 00:49:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BA=91=E5=90=8C=E6=AD=A5,=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=8A=9F=E8=83=BD=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codegraph/.gitignore | 16 + README.md | 139 ++++-- .../08-cloud_sync_refactor_plan.md | 332 ++++++++++++++ scripts/refresh_codegraph_parent.ps1 | 71 +++ .../Auth/ClaimsPrincipalExtensions.cs | 9 - .../CloudSync/Auth/CloudClaims.cs | 4 - .../Auth/SessionAuthenticationHandler.cs | 5 - .../CloudSync/CloudSyncEndpointExtensions.cs | 74 ++- .../CloudSyncServiceCollectionExtensions.cs | 12 + .../CloudSync/Models/AdminDtos.cs | 1 - .../CloudSync/Models/AuthDtos.cs | 20 - .../CloudSync/Models/CloudApiErrors.cs | 6 - .../CloudSync/Models/ProbeDtos.cs | 48 ++ .../CloudSync/Models/SecurityPolicyDtos.cs | 15 - .../CloudSync/Services/CloudAdminService.cs | 5 +- .../CloudSync/Services/CloudAuthService.cs | 69 +-- .../CloudSync/Services/CloudProbeService.cs | 135 ++++++ .../Services/CloudTaskSyncService.cs | 127 ++++-- .../Services/SecurityPolicyService.cs | 7 +- .../DynamicApi/DynamicApiMiddleware.cs | 9 + src/Hua.Todo.Host/wwwroot/admin/index.html | 5 - src/Hua.Todo.Maui/appsettings.json | 4 +- src/Hua.Todo.Web/.env.development | 2 + src/Hua.Todo.Web/.env.maui | 2 + src/Hua.Todo.Web/.env.production | 2 + src/Hua.Todo.Web/src/api/cloudClient.ts | 35 +- src/Hua.Todo.Web/src/api/cloudSync.ts | 108 ++++- src/Hua.Todo.Web/src/api/tasks.ts | 23 +- .../components/CloudSyncSettingsDialog.vue | 424 ++++++++++++------ src/Hua.Todo.Web/src/components/TaskItem.vue | 38 +- src/Hua.Todo.Web/src/components/TaskList.vue | 93 +++- .../src/services/cloudSyncState.ts | 11 - .../src/services/localStorageService.ts | 91 +++- src/Hua.Todo.Web/vite.config.ts | 61 ++- 34 files changed, 1532 insertions(+), 471 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 docs/project/v1.2.0-tasks/08-cloud_sync_refactor_plan.md create mode 100644 scripts/refresh_codegraph_parent.ps1 create mode 100644 src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs create mode 100644 src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs create mode 100644 src/Hua.Todo.Web/.env.development create mode 100644 src/Hua.Todo.Web/.env.maui create mode 100644 src/Hua.Todo.Web/.env.production diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/README.md b/README.md index 68dc3ba..d772715 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,27 @@ # Hua.Todo 跨平台代办管理应用 v1.2.8 -一个基于 WebView 容器(MAUI / Avalonia)+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。 +一个基于 **WebView 容器(MAUI / Avalonia)+ 嵌入式 ASP.NET Core WebServer** 架构开发的跨平台代办管理应用,支持 **Windows、macOS、Android、iOS 和 Linux** 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。 ## 🚀 功能特点 ### 核心功能 -- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux。 -- **任务管理**:支持创建、编辑、删除、完成状态切换、子任务管理。 -- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略(SecurityPolicy)配置。 -- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感。 -- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析。 -- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试。 +- **跨平台支持**:基于 MAUI + Avalonia + WebView 架构,覆盖 Windows、macOS、Android、iOS 和 Linux 五大平台 +- **任务管理**:支持任务创建、编辑、删除、完成状态切换,以及子任务管理 +- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略配置 +- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感 +- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析 +- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试 +- **全局快捷键**:支持系统级快捷键快速唤起应用 ## 📦 安装与使用 ### 环境要求 -- **后端**: - - .NET 10 SDK - - Visual Studio 2022 或更高版本 -- **前端**: - - Node.js 18+ - - npm 或 yarn +- **后端**:.NET 10 SDK + Visual Studio 2022 或更高版本 +- **前端**:Node.js 18+ + npm 或 yarn ### 快速开始 -#### 1. 克隆或下载项目 +#### 1. 克隆项目 ```bash git clone <仓库地址> cd Hua.Todo @@ -36,8 +33,8 @@ cd src/Hua.Todo.Host dotnet restore dotnet run ``` -API 将在 `http://localhost:5173` 启动 -开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI:`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`) +API 将在 `http://localhost:5173` 启动 +开发环境下提供 Swagger UI:`http://localhost:5173/swagger` #### 3. 启动前端 Web ```bash @@ -45,31 +42,38 @@ cd src/Hua.Todo.Web npm install npm run dev ``` -前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173` +前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到后端 #### 4. 启动 MAUI 客户端(Windows 三件套开发) -- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)` 与 `Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。 -- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI): - +推荐使用脚本一键启动: ```powershell .\start-dev.ps1 ``` -### Windows 交付产物(安装包) +### 构建与发布 -- 运行 `publish-windows.ps1` 默认使用 `Release` 配置生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj` 的 ``;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows//installer/`) -- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows:`publish.ps1 -Windows`),且均默认使用 `Release` 配置。 -- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件) -- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json` 中 `WebServer.IsUsingStatic=true` -- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件) +#### Windows 交付产物 +```powershell +# 生成 Windows 安装包(Inno Setup) +.\publish-windows.ps1 +``` +输出:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe` -### Linux 交付产物(v1.2.0) - -- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux`) -- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/` +#### Linux 交付产物 +```powershell +# 生成 Linux 发布包 +.\publish-linux.ps1 +``` +或使用统一发布脚本: +```powershell +# 默认发布 Windows + Linux +.\publish.ps1 +# 仅发布 Windows +.\publish.ps1 -Windows +``` ### 使用说明 -- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮 +- **添加任务**:在前端界面输入任务内容,设置优先级,点击添加按钮 - **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成) - **完成任务**:点击任务前的复选框切换完成状态 - **删除任务**:点击删除按钮移除任务 @@ -86,31 +90,70 @@ Hua.Todo/ ├── src/ # 源代码目录 │ ├── Hua.Todo.Core/ # 领域实体与基础接口 │ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现 +│ │ ├── CloudSync/ # 云同步模块 +│ │ ├── Data/ # 数据访问层 +│ │ └── DynamicApi/ # 动态 API 生成 │ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel) │ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite) -│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS) -│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux) -│ └── Hua.Todo.slnx # 解决方案文件 +│ ├── Hua.Todo.Maui/ # 跨平台客户端 (Windows/Android/iOS/macOS) +│ └── Hua.Todo.Avalonia/ # 桌面客户端 (Windows/macOS/Linux) ├── .gitignore # Git 忽略文件 └── README.md # 项目说明文档 ``` +### 技术栈 + +| 分类 | 技术 | +|------|------| +| 后端语言 | C# 13 (.NET 10) | +| UI 框架 | MAUI + Avalonia | +| 服务器 | Kestrel (ASP.NET Core) | +| 数据访问 | Entity Framework Core 10 | +| 数据库 | SQLite | +| 前端框架 | Vue.js 3 + TypeScript | +| 构建工具 | Vite 5+ | +| HTTP 客户端 | Axios | + ### API 端点 -- `GET /api/task` - 获取任务列表(默认:全部) -- `GET /api/task/active` - 获取未完成任务 -- `GET /api/task/completed` - 获取已完成任务 -- `GET /api/task/{id}` - 获取单个任务 -- `POST /api/task` - 创建任务 -- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位) -- `PATCH /api/task/{id}/toggle` - 切换完成状态 -- `DELETE /api/task/{id}` - 删除任务 -- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表 + +#### 任务管理 +| 方法 | 端点 | 说明 | +|------|------|------| +| GET | `/api/task` | 获取任务列表(默认:全部) | +| GET | `/api/task/active` | 获取未完成任务 | +| GET | `/api/task/completed` | 获取已完成任务 | +| GET | `/api/task/{id}` | 获取单个任务 | +| POST | `/api/task` | 创建任务 | +| PUT | `/api/task` | 更新任务(通过 Body 内的 id 定位) | +| PATCH | `/api/task/{id}/toggle` | 切换完成状态 | +| DELETE | `/api/task/{id}` | 删除任务 | +| GET | `/api/task/{parentTaskId}/subtasks` | 获取子任务列表 | + +#### 云同步(Host 模式) +| 方法 | 端点 | 说明 | +|------|------|------| +| POST | `/auth/bootstrap` | 初始化管理员(仅首次) | +| POST | `/auth/login` | 用户登录 | +| POST | `/auth/step-up` | 二次验证(提升权限) | +| GET | `/tasks/` | 获取云端任务(只读) | +| POST | `/sync/` | 推送/拉取合并同步 | +| GET | `/security/policy` | 获取安全策略 | +| PUT | `/security/policy` | 更新安全策略 | + +## 📊 模块说明 + +- **Hua.Todo.Core**:领域实体层,定义核心实体、枚举及仓储接口 +- **Hua.Todo.Application**:应用层,包含业务逻辑、动态 API 生成、云同步服务 +- **Hua.Todo.Host**:后端 API 宿主,提供独立运行时环境 +- **Hua.Todo.Web**:前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite +- **Hua.Todo.Maui**:跨平台客户端,支持 Windows、Android、iOS 和 macOS +- **Hua.Todo.Avalonia**:桌面客户端,专注 Linux 平台支持,同时兼容 Windows/macOS ## 🤝 交流与贡献 - **QQ 交流群**:2167048911 (Hua.Todo 交流群) - **项目地址**:[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo) -- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md) +- **贡献指南**:欢迎提交 Pull Request ## 📄 开源协议 @@ -123,12 +166,14 @@ Hua.Todo/ - [版本更新历史](docs/manual/版本记录.md) - [技术设计文档](docs/manual/技术设计文档.md) - [代码规范文档](docs/manual/代码规范文档.md) -- [其他信息 (贡献、许可证、联系方式)](docs/manual/其他信息.md) +- [部署文档](docs/manual/部署文档.md) +- [其他信息](docs/manual/其他信息.md) ### 项目进度与需求 - [产品需求文档](docs/project/产品需求文档.md) -- [Android 离线排查计划](docs/project/Android_NotFound_排查计划.md) +- [v1.2.0 任务清单](docs/project/v1.2.0-tasks/00-任务总览.md) - [实现对比文档](docs/project/实现对比文档.md) --- -**Hua.Todo** - 跨平台任务管理,让效率无处不在! + +**Hua.Todo** - 跨平台任务管理,让效率无处不在! \ No newline at end of file diff --git a/docs/project/v1.2.0-tasks/08-cloud_sync_refactor_plan.md b/docs/project/v1.2.0-tasks/08-cloud_sync_refactor_plan.md new file mode 100644 index 0000000..405ca2b --- /dev/null +++ b/docs/project/v1.2.0-tasks/08-cloud_sync_refactor_plan.md @@ -0,0 +1,332 @@ +## 云同步设置 — 服务端化改造方案 + +### 1. 当前架构概述 + +#### 1.1 项目层次关系 + +``` +src/ +├── Hua.Todo.Application/ ← 共享层(服务端 & 客户端公用) +│ ├── CloudSync/ ← 云同步业务逻辑 +│ │ ├── Auth/ ← 认证/授权 +│ │ ├── Models/ ← DTO(纯数据合约,两端可用) +│ │ └── Services/ ← 业务服务 +│ ├── Data/ ← EF Core DbContext +│ └── DynamicApi/ ← 动态 API 中间件 +│ +├── Hua.Todo.Host/ ← ASP.NET 服务端(云同步接口的提供方) +│ └── Program.cs +│ ├── AddApplicationServices() ← 任务 CRUD 服务 +│ └── AddCloudSyncServer() ← 云同步认证/授权/端点 **仅服务端** +│ +└── Hua.Todo.Maui/ ← 桌面/移动客户端(嵌入 WebView) + └── MauiProgram.cs + └── AddApplicationServices() ← **仅** 任务 CRUD 服务 + (不注册 AddCloudSyncServer,不暴露云同步端点) +``` + +#### 1.2 两种部署模式 + +**模式 A:Host 独立服务端(开发/部署)** + +``` +┌───────────────────────────────┐ +│ Vue 前端 (Vite :5174) │ +│ cloudClient │ +│ baseURL = serverUrl(外部) │ 直连外部 +│ → /auth/login │ ────────→ 外部云同步服务 +│ probeReachability() │ 直连外部 +│ → fetch(serverUrl) │ ────────→ +├───────────────────────────────┤ +│ proxy /api │ +├───────────────────────────────┤ +│ ASP.NET Host (:5173) │ +│ AddApplicationServices() │ 任务 CRUD +│ AddCloudSyncServer() │ 云同步端点 ← **服务端** +│ ├─ /api/* (本地任务) │ +│ └─ /auth/* (云同步) │ +│ /tasks/* │ +│ /cloud-sync/probe (新增) │ +└───────────────────────────────┘ +``` + +**模式 B:MAUI 嵌入式 WebView(桌面端/移动端)** + +``` +┌───────────────────────────────┐ +│ MAUI WebView │ +│ ┌─────────────────────────┐ │ +│ │ Vue 前端 (静态托管) │ │ +│ │ cloudClient │ │ +│ │ baseURL = 同源 │ │ 同源请求 +│ │ → /api/* (本地任务) │──┼───────→ Embedded WebServer +│ │ → /auth/* (❌ 不可用) │ │ (不暴露云同步端点) +│ └─────────────────────────┘ │ +├───────────────────────────────┤ +│ Embedded WebServer (:5057) │ +│ AddApplicationServices() │ 仅任务 CRUD +│ AddCloudSyncServer() │ ❌ 未注册 +│ UseDynamicApi() │ 仅本地任务 API +│ MapCloudSyncEndpoints() │ ❌ 未映射 +└───────────────────────────────┘ +``` + +> **关键区别**:`Hua.Todo.Application` 被两方引用,但云同步能力**仅在 Host 端通过 `AddCloudSyncServer()` 激活**。MAUI 端只使用 `AddApplicationServices()` 获取任务 CRUD 能力,**不暴露也不应暴露云同步端点**。 + +#### 1.3 核心问题 + +1. 前端的"保存并探测"使用**浏览器端直接 `fetch()`** 探测外部 URL,受 CORS、证书、网络隔离限制。 +2. 前端的"登录"通过 `cloudClient` 直连外部 `serverUrl`,session token 存在前端 `sessionStorage`,与 Host 脱节。 +3. 在 Host 模式下,Host 本身就是云同步服务,但前端并不知道,额外配置了外部 `serverUrl`。 + +### 2. 改造目标 + +| 操作 | 当前 | 目标 | +|---|---|---| +| **保存并探测** | 浏览器 `fetch()` 直连 | 前端 → Host 接口 → Application CloudProbeService → 返回结果 | +| **登录** | 前端直连 `serverUrl` | 前端 → Host `/auth/login` | +| **后续云同步请求** | `cloudClient` 直连 `serverUrl` | 统一走 Host(同源请求) | + +### 3. 变更详情 + +#### 3.1 新增:服务端探测接口 + +> **注意**:`CloudProbeService` 和服务端点注册均在 `AddCloudSyncServer()` 路径下,MAUI 端不受影响。 + +**Application 层新增 `CloudProbeService`** + +位置:`src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs` + +职责:接收目标 URL,从服务端发起 HTTP 探测,返回探测结果。 + +**新增 DTO**(位置:`src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs`,或新建 `CloudSync/Models/ProbeDtos.cs`) + +DTO 是纯数据合约,放在 Application 共享层无副作用,MAUI 端即使不调用也不会产生依赖。 + +```csharp +/// +/// 服务端探测请求。 +/// +public class ProbeRequest +{ + public string TargetUrl { get; set; } = string.Empty; +} + +/// +/// 服务端探测响应。 +/// +public class ProbeResponse +{ + public bool IsReachable { get; set; } + public int? HttpStatus { get; set; } + public bool IsHttps { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + /// + /// success | warn | error + /// + public string Type { get; set; } = "error"; +} +``` + +**DI 注册(`CloudSyncServiceCollectionExtensions.AddCloudSyncServer()`)** + +在 `AddCloudSyncServer()` 方法末尾追加,确保 `CloudProbeService` 仅在服务端 DI 容器中可用: + +```csharp +// CloudSyncServiceCollectionExtensions.AddCloudSyncServer() 末尾新增: +services.AddHttpClient("ProbeClient", client => +{ + client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0"); +}); +services.AddScoped(); +``` + +> **为什么放在 `AddCloudSyncServer()` 而非 `AddApplicationServices()`**: +> - `AddApplicationServices()` 被 MAUI 端调用,MAUI 不需要也不会启动云同步探测 +> - `AddCloudSyncServer()` 仅在 Host 的 `Program.cs` 中被调用,确保服务仅注册在服务端 + +**端点注册(`CloudSyncEndpointExtensions.MapCloudSyncEndpoints()`)** + +在 `MapCloudSyncEndpoints()` 方法内部新增端点组: + +```csharp +var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup"); +probe.MapPost("/probe", ProbeAsync).AllowAnonymous(); +``` + +端点实现: + +```csharp +private static async Task ProbeAsync( + ProbeRequest request, + CloudProbeService probeService, + CancellationToken cancellationToken) +{ + if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl)) + { + return CloudApiErrors.BadRequest("TargetUrl is required."); + } + var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken); + return Results.Json(result); +} +``` + +**前端修改** `src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue` + +删除 `probeReachability()` 函数(原 L235-L280),`saveServerUrl()` 中改为: + +```typescript +// 旧:前端直接 fetch +// probeResult.value = await probeReachability(serverUrlSaved.value); + +// 新:调用 Host 的探测接口 +const response = await cloudClient.post('/cloud-sync/probe', { + targetUrl: serverUrlSaved.value, +}); +probeResult.value = { + type: response.data.type as ProbeType, + title: response.data.title, + desc: response.data.description, +}; +``` + +#### 3.2 修改:登录统一走 Host + +**核心思路**:Host 自身就是云同步服务方,`/auth/login` 已实现完整认证。前端只需改为走 Host 同源请求。 + +**前端 `cloudClient.ts` 改造**(`src/Hua.Todo.Web/src/api/cloudClient.ts`) + +移除对外部 `serverUrl` 的依赖: + +```typescript +// 改造前(L36-L39): +// const { serverUrl } = CloudSyncStorage.loadSettings(); +// if (serverUrl) { config.baseURL = serverUrl; } + +// 改造后:不设 baseURL,axios 使用浏览器默认同源 +// cloudClient 请求直接打到当前 Host:/auth/* , /tasks/* , /cloud-sync/* +``` + +> **同源适配说明**: +> - **Host 模式**(Vite dev):Vue 运行在 :5174,`/api` 走 proxy 到 Host(:5173),云同步端点 `/auth/*` 需配 Vite proxy 或直接用 Host 地址 +> - **MAUI 模式**(WebView):Vue 部署在 MAUI 嵌入服务器同源,不需要额外代理 +> - 具体方案:在 Vite dev 时增加 proxy 规则将 `/auth`、`/tasks`、`/cloud-sync` 也 proxy 到 Host;或在 cloudClient 中按环境设置 baseURL + +**前端 `CloudSyncSettingsDialog.vue` 的 `login()` 无需修改**: +`cloudSyncApi.login()` 调用 `cloudClient.post('/auth/login', ...)`,自动打到 Host。 + +**Host 端 `CloudAuthService.LoginAsync` 已有完整实现**: +- 创建 `UserSessionEntity` 写入 DB(session 管理在 Host) +- 返回 `LoginResponse`(`AccessToken` = DB SessionId) +- Session 验证由 `SessionAuthenticationHandler` 查询 DB 完成 + +#### 3.3 Vite 代理配置补充 + +Vite dev 模式下,除了已有的 `/api` 代理,需**补充云同步端点的代理规则**(`vite.config.ts`): + +```typescript +server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:5173', + changeOrigin: true, + secure: false, + }, + // 新增:云同步端点代理到 Host + '/auth': { + target: 'http://localhost:5173', + changeOrigin: true, + secure: false, + }, + '/tasks': { + target: 'http://localhost:5173', + changeOrigin: true, + secure: false, + }, + '/sync': { + target: 'http://localhost:5173', + changeOrigin: true, + secure: false, + }, + '/security': { + target: 'http://localhost:5173', + changeOrigin: true, + secure: false, + }, + '/cloud-sync': { + target: 'http://localhost:5173', + changeOrigin: true, + secure: false, + }, + }, +}, +``` + +> **MAUI 模式无需此配置**:MAUI 使用 `vite build --mode maui`,静态部署到嵌入服务器同源。 + +#### 3.4 `serverUrl` 字段的语义变化 + +| 维度 | 改造前 | 改造后 | +|---|---|---| +| **用途** | 云同步 API 的 base URL(前端直连) | 仅用于"服务端探测"的目标地址 | +| **存储位置** | `localStorage` | 不变(探测时使用) | +| **影响登录/任务拉取** | 是(前端用它做 baseURL) | 否(走 Host 自身端点) | + +### 4. 需要变更的文件清单 + +| 层 | 文件 | 变更类型 | 说明 | +|---|---|---|---| +| Application | `Services/CloudProbeService.cs` | **新增** | 服务端探测逻辑,注册于 `AddCloudSyncServer()` | +| Application | `Models/AdminDtos.cs`(或新 `Models/ProbeDtos.cs`) | **新增 DTO** | `ProbeRequest` / `ProbeResponse`(纯数据合约) | +| Application | `CloudSyncEndpointExtensions.cs` | **修改** | 注册 `POST /cloud-sync/probe` | +| Application | `CloudSyncServiceCollectionExtensions.cs` | **修改** | `AddCloudSyncServer()` 内注册 `CloudProbeService` + `HttpClient` | +| Host | `Program.cs` | **无需修改** | `MapCloudSyncEndpoints()` 自动包含新端点 | +| Web(Vue) | `vite.config.ts` | **修改** | 补充云同步端点的 dev proxy | +| Web(Vue) | `api/cloudClient.ts` | **修改** | 移除外部 `serverUrl` baseURL | +| Web(Vue) | `api/cloudSync.ts` | **修改** | 新增 `probeServerUrl()` 方法 | +| Web(Vue) | `components/CloudSyncSettingsDialog.vue` | **修改** | 删除 `probeReachability()`,改用 API 调用 | + +| 层 | 文件 | 变更 | 说明 | +|---|---|---|---| +| MAUI | `MauiProgram.cs` | **无需修改** | 不注册 `AddCloudSyncServer()` | +| MAUI | `EmbeddedWebServerService.cs` | **无需修改** | 不映射 `MapCloudSyncEndpoints()` | + +### 5. 端点路由变更汇总 + +| 路由 | 方法 | 变更 | 可用环境 | +|---|---|---|---| +| `/cloud-sync/probe` | POST | **新增** | Host(Vite dev proxy)/ MAUI(同源请求到 Host) | +| `/auth/login` | POST | 无变更 | 同上 | +| `/auth/step-up` | POST | 无变更 | 同上 | +| `/tasks/` | GET | 无变更 | 同上 | +| `/security/policy` | GET | 无变更 | 同上 | + +### 6. 服务端/客户端职责边界总结 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Hua.Todo.Application (共享层) │ +│ │ +│ AddApplicationServices() AddCloudSyncServer() │ +│ ├─ TodoDbContext ├─ CloudAuthService │ +│ ├─ TaskRepository ├─ CloudAdminService │ +│ ├─ TaskService ├─ CloudTaskSyncService │ +│ ├─ DynamicApi ├─ SecurityPolicyService │ +│ └─ DTOs (Models/*) ├─ CloudProbeService (新) │ +│ ↑ 纯数据合约,两端安全 ├─ Authentication/Policy │ +│ └─ MapCloudSyncEndpoints │ +│ │ +│ Host 注册: AddApplicationServices + AddCloudSyncServer │ +│ MAUI 注册: AddApplicationServices only │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 7. 安全考量 + +| 风险点 | 缓解措施 | +|---|---| +| SSRF(探测端点攻击内网) | `CloudProbeService` 限制目标 URL 必须是 HTTP/HTTPS 公网地址,禁止探测 localhost/内网 IP | +| 探测请求被滥用 | 加频率限制(如每分钟 3 次),仅允许同源请求 | +| MAUI 端不暴露云同步端点 | `AddCloudSyncServer()` 仅在 Host 调用,MAUI 无法访问云同步端点 | diff --git a/scripts/refresh_codegraph_parent.ps1 b/scripts/refresh_codegraph_parent.ps1 new file mode 100644 index 0000000..29d4d39 --- /dev/null +++ b/scripts/refresh_codegraph_parent.ps1 @@ -0,0 +1,71 @@ +param( + [switch]$Init, + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function Install-CodeGraph { + Write-Host "[信息] 正在安装 codegraph…" -ForegroundColor Cyan + + $npm = Get-Command npm -ErrorAction SilentlyContinue + if ($npm) { + Write-Host "[信息] 通过 npm 安装 @colbymchenry/codegraph…" -ForegroundColor Cyan + npm install -g @colbymchenry/codegraph + if ($LASTEXITCODE -eq 0) { + Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green + return + } + Write-Host "[警告] npm 安装失败,尝试使用官方安装脚本…" -ForegroundColor Yellow + } + + Write-Host "[信息] 通过官方脚本安装…" -ForegroundColor Cyan + irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex + if ($LASTEXITCODE -ne 0) { throw "codegraph 安装失败" } + Write-Host "[完成] codegraph 安装成功" -ForegroundColor Green +} + +# 切换到上级目录(脚本所在目录的上级) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$parentDir = Split-Path -Parent $scriptDir +Push-Location $parentDir +Write-Host "[信息] 工作目录已切换到: $parentDir" -ForegroundColor Cyan + +try { + $codegraph = Get-Command codegraph -ErrorAction Stop + Write-Host "[正常] 已检测到 codegraph CLI" -ForegroundColor Green +} catch { + Write-Host "[提示] 未找到 codegraph,将自动安装…" -ForegroundColor Yellow + Install-CodeGraph +} + +if (-not (Test-Path ".\.codegraph")) { + Write-Host "[警告] 未找到 .codegraph/ 目录,正在执行初始化…" -ForegroundColor Yellow + codegraph init -i + if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" } + Write-Host "[完成] 初始化 + 索引构建完毕" -ForegroundColor Green +} elseif ($Init) { + Write-Host "[信息] 正在重新初始化…" -ForegroundColor Cyan + codegraph init -i + if ($LASTEXITCODE -ne 0) { throw "codegraph init 失败" } + Write-Host "[完成] 重新初始化 + 索引构建完毕" -ForegroundColor Green +} elseif ($Force) { + Write-Host "[信息] 正在执行全量重建索引…" -ForegroundColor Cyan + codegraph index --force + if ($LASTEXITCODE -ne 0) { throw "codegraph index --force 失败" } + Write-Host "[完成] 全量重建索引完毕" -ForegroundColor Green +} else { + Write-Host "[信息] 正在执行增量同步…" -ForegroundColor Cyan + codegraph sync + if ($LASTEXITCODE -ne 0) { throw "codegraph sync 失败" } + Write-Host "[完成] 增量同步完毕" -ForegroundColor Green +} + +Write-Host "`n--- CodeGraph 状态 ---" -ForegroundColor Cyan +codegraph status + +if ($LASTEXITCODE -ne 0) { + Write-Host "[警告] codegraph status 返回异常" -ForegroundColor Yellow +} + +Pop-Location diff --git a/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs b/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs index 4c89fc8..6390217 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/Auth/ClaimsPrincipalExtensions.cs @@ -40,14 +40,5 @@ public static class ClaimsPrincipalExtensions return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase)); } - /// - /// 判断是否已完成二次认证(step-up)。 - /// - /// 当前用户主体。 - /// 是否已完成二次认证。 - public static bool HasStepUp(this ClaimsPrincipal user) - { - return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase)); - } } diff --git a/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs b/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs index 0e7770e..b3af006 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs +++ b/src/Hua.Todo.Application/CloudSync/Auth/CloudClaims.cs @@ -10,9 +10,5 @@ public static class CloudClaims /// public const string Permission = "perm"; - /// - /// 二次认证状态 Claim。 - /// - public const string StepUp = "step_up"; } diff --git a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs b/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs index 4d8074b..d429f2b 100644 --- a/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs +++ b/src/Hua.Todo.Application/CloudSync/Auth/SessionAuthenticationHandler.cs @@ -100,11 +100,6 @@ public class SessionAuthenticationHandler : AuthenticationHandler now) - { - claims.Add(new Claim(CloudClaims.StepUp, "true")); - } - var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs index c6ddff4..a359df4 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs @@ -21,7 +21,7 @@ public static class CloudSyncEndpointExtensions var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth"); auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous(); auth.MapPost("/login", LoginAsync).AllowAnonymous(); - auth.MapPost("/step-up", StepUpAsync).RequireAuthorization(); + auth.MapPost("/logout", LogoutAsync).RequireAuthorization(); auth.MapPost("/change-password", ChangePasswordAsync).RequireAuthorization(); var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks"); @@ -43,6 +43,10 @@ public static class CloudSyncEndpointExtensions admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync); admin.MapGet("/audit-logs", GetAuditLogsAsync); + // 服务端探测端点(允许匿名访问,用于探测用户配置的外部服务端) + var probe = app.MapGroup("/cloud-sync").WithTags("CloudSync - Setup"); + probe.MapPost("/probe", ProbeAsync).AllowAnonymous(); + return app; } @@ -95,33 +99,6 @@ public static class CloudSyncEndpointExtensions return Results.Json(response); } - private static async Task StepUpAsync( - StepUpRequest request, - CloudAuthService authService, - HttpContext httpContext, - CancellationToken cancellationToken) - { - var sessionId = httpContext.User.GetSessionId(); - if (sessionId == null) - { - return CloudApiErrors.Unauthorized(); - } - - if (request == null || string.IsNullOrWhiteSpace(request.Password)) - { - return CloudApiErrors.BadRequest("Password is required."); - } - - var (ip, ua) = GetClientInfo(httpContext); - var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, ip, ua, cancellationToken); - if (!expiresAt.HasValue) - { - return CloudApiErrors.Unauthorized("Invalid credentials or session expired."); - } - - return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value }); - } - private static async Task ChangePasswordAsync( ChangePasswordRequest request, CloudAuthService authService, @@ -154,6 +131,21 @@ public static class CloudSyncEndpointExtensions return Results.Ok(); } + private static async Task LogoutAsync( + CloudAuthService authService, + HttpContext httpContext, + CancellationToken cancellationToken) + { + var sessionId = httpContext.User.GetSessionId(); + if (sessionId == null) + { + return CloudApiErrors.Unauthorized(); + } + + await authService.LogoutAsync(sessionId.Value, cancellationToken); + return Results.Ok(); + } + private static async Task GetTasksAsync( CloudTaskSyncService taskService, HttpContext httpContext, @@ -191,11 +183,6 @@ public static class CloudSyncEndpointExtensions return CloudApiErrors.Forbidden(); } - if (!httpContext.User.HasStepUp()) - { - return CloudApiErrors.SecondFactorRequired(); - } - var response = await taskService.SyncAsync(userId.Value, request, cancellationToken); return Results.Json(response); } @@ -237,12 +224,7 @@ public static class CloudSyncEndpointExtensions return CloudApiErrors.Forbidden(); } - if (!httpContext.User.HasStepUp()) - { - return CloudApiErrors.SecondFactorRequired(); - } - - var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.SecondFactorExpiryMinutes, request.IsTrustedDeviceOnly, cancellationToken); + var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.IsTrustedDeviceOnly, cancellationToken); return Results.Json(policy); } @@ -345,5 +327,19 @@ public static class CloudSyncEndpointExtensions return Results.Json(logs); } + private static async Task ProbeAsync( + ProbeRequest request, + CloudProbeService probeService, + CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.TargetUrl)) + { + return CloudApiErrors.BadRequest("TargetUrl is required."); + } + + var result = await probeService.ProbeAsync(request.TargetUrl, cancellationToken); + return Results.Json(result); + } + #endregion } diff --git a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs index 85a018a..e862087 100644 --- a/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs +++ b/src/Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs @@ -29,6 +29,18 @@ public static class CloudSyncServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // 注册探测服务专用的 HttpClient(仅限服务端,MAUI 端不注册 AddCloudSyncServer) + // 关闭 SSL 证书校验:探测目标可能是自签证书或内网服务器 + services.AddHttpClient("ProbeClient", client => + { + client.DefaultRequestHeaders.UserAgent.ParseAdd("HuaTodo-Probe/1.0"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + services.AddScoped(); + services.AddAuthentication(SessionAuthenticationDefaults.Scheme) .AddScheme(SessionAuthenticationDefaults.Scheme, _ => { }); diff --git a/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs index e4b5c56..fe3372b 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/AdminDtos.cs @@ -56,5 +56,4 @@ public class SessionDto public string UserName { get; set; } = string.Empty; public DateTime CreatedAtUtc { get; set; } public DateTime ExpiresAtUtc { get; set; } - public bool IsSteppedUp { get; set; } } diff --git a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs index a0e178d..f523875 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/AuthDtos.cs @@ -100,25 +100,5 @@ public class ChangePasswordRequest public string NewPassword { get; set; } = string.Empty; } -/// -/// 二次认证(step-up)请求。 -/// -public class StepUpRequest -{ - /// - /// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。 - /// - public string Password { get; set; } = string.Empty; -} -/// -/// 二次认证(step-up)响应。 -/// -public class StepUpResponse -{ - /// - /// 二次认证有效期截止(UTC)。 - /// - public DateTime StepUpExpiresAtUtc { get; set; } -} diff --git a/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs b/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs index 5a43dba..cb2ef51 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/CloudApiErrors.cs @@ -33,12 +33,6 @@ public static class CloudApiErrors public static IResult Forbidden(string message = "Forbidden.") => Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message); - /// - /// 需要二次认证(step-up)。 - /// - public static IResult SecondFactorRequired(string message = "Second factor required.") - => Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", message); - /// /// 请求非法。 /// diff --git a/src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs new file mode 100644 index 0000000..a38e1c4 --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Models/ProbeDtos.cs @@ -0,0 +1,48 @@ +namespace Hua.Todo.Application.CloudSync.Models; + +/// +/// 服务端探测请求。 +/// +public class ProbeRequest +{ + /// + /// 目标 URL。 + /// + public string TargetUrl { get; set; } = string.Empty; +} + +/// +/// 服务端探测响应。 +/// +public class ProbeResponse +{ + /// + /// 是否可达。 + /// + public bool IsReachable { get; set; } + + /// + /// HTTP 状态码(如果探测失败则为 null)。 + /// + public int? HttpStatus { get; set; } + + /// + /// 是否使用 HTTPS。 + /// + public bool IsHttps { get; set; } + + /// + /// 页面标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 描述信息。 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 结果类型:success | warn | error。 + /// + public string Type { get; set; } = "error"; +} diff --git a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs b/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs index b223d96..4ccb687 100644 --- a/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs +++ b/src/Hua.Todo.Application/CloudSync/Models/SecurityPolicyDtos.cs @@ -15,16 +15,6 @@ public class SecurityPolicyDto /// public bool AllowSync { get; set; } - /// - /// 需要二次认证的操作列表(操作码)。 - /// - public List RequireSecondFactorFor { get; set; } = new(); - - /// - /// 二次认证有效期(分钟)。 - /// - public int SecondFactorExpiryMinutes { get; set; } - /// /// 是否仅限受信任设备。 /// @@ -46,11 +36,6 @@ public class UpdateSecurityPolicyRequest /// public bool AllowSync { get; set; } - /// - /// 二次认证有效期(分钟)。 - /// - public int SecondFactorExpiryMinutes { get; set; } - /// /// 是否仅限受信任设备。 /// diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs index 587a730..c05ef12 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudAdminService.cs @@ -58,7 +58,7 @@ public class CloudAdminService _dbContext.Users.Add(user); // 为新用户创建默认策略 - _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id }); + _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true }); await _dbContext.SaveChangesAsync(cancellationToken); @@ -103,8 +103,7 @@ public class CloudAdminService UserId = s.UserId, UserName = s.User != null ? s.User.UserName : "Unknown", CreatedAtUtc = s.CreatedAtUtc, - ExpiresAtUtc = s.ExpiresAtUtc, - IsSteppedUp = s.StepUpExpiresAtUtc.HasValue && s.StepUpExpiresAtUtc.Value > now + ExpiresAtUtc = s.ExpiresAtUtc }) .ToListAsync(cancellationToken); } diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs index b02c60d..69c85d2 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudAuthService.cs @@ -138,7 +138,7 @@ public class CloudAuthService user.PasswordHash = _passwordHasher.HashPassword(user, generatedPassword); _dbContext.Users.Add(user); - _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }); + _dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true }); await _dbContext.SaveChangesAsync(cancellationToken); await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); @@ -197,7 +197,7 @@ public class CloudAuthService var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); if (policy == null) { - policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true }; + policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true, AllowSync = true }; _dbContext.SecurityPolicies.Add(policy); } @@ -217,55 +217,6 @@ public class CloudAuthService } /// - /// 通过再输入口令提升会话权限(step-up)。 - /// - /// 会话 ID。 - /// 口令。 - /// 客户端 IP。 - /// User-Agent。 - /// 取消令牌。 - /// 有效期截止时间;失败返回 null。 - public async Task StepUpAsync(Guid sessionId, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(password)) - { - return null; - } - - var now = DateTime.UtcNow; - - var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); - if (session == null || session.ExpiresAtUtc <= now) - { - return null; - } - - var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken); - if (user == null) - { - return null; - } - - var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); - if (verify == PasswordVerificationResult.Failed) - { - await LogAsync("StepUpFailed", "Invalid password during step-up.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent); - return null; - } - - var policy = await _dbContext.SecurityPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); - var expiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30; - if (expiryMinutes <= 0) expiryMinutes = 30; - - var stepUpExpiresAt = now.AddMinutes(expiryMinutes); - session.StepUpExpiresAtUtc = stepUpExpiresAt; - await _dbContext.SaveChangesAsync(cancellationToken); - - await LogAsync("StepUpSuccess", $"Session stepped up for {expiryMinutes} minutes.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); - - return stepUpExpiresAt; - } - /// /// 修改用户密码。 /// @@ -317,5 +268,21 @@ public class CloudAuthService await LogAsync("ChangePasswordSuccess", "Password changed successfully.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent); return true; } + + /// + /// 登出:从数据库移除当前会话,使 Token 立即失效。 + /// + /// 会话 ID。 + /// 取消令牌。 + /// 是否成功移除。 + public async Task LogoutAsync(Guid sessionId, CancellationToken cancellationToken) + { + var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); + if (session == null) return false; + + _dbContext.UserSessions.Remove(session); + await _dbContext.SaveChangesAsync(cancellationToken); + return true; + } } diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs new file mode 100644 index 0000000..d36d16d --- /dev/null +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudProbeService.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Net.Http; +using Hua.Todo.Application.CloudSync.Models; + +namespace Hua.Todo.Application.CloudSync.Services; + +/// +/// 服务端探测服务。负责从服务端发起 HTTP 探测,验证目标地址可达性。 +/// +public class CloudProbeService +{ + private readonly IHttpClientFactory _httpClientFactory; + + /// + /// 创建 。 + /// + /// HTTP 客户端工厂。 + public CloudProbeService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + /// + /// 探测目标 URL 的可达性。 + /// + /// 目标 URL。 + /// 取消令牌。 + /// 探测响应。 + public async Task ProbeAsync(string targetUrl, CancellationToken cancellationToken) + { + var response = new ProbeResponse(); + + if (string.IsNullOrWhiteSpace(targetUrl)) + { + response.Type = "error"; + response.Description = "目标地址不能为空。"; + return response; + } + + Uri uri; + try + { + uri = new Uri(targetUrl); + } + catch + { + response.Type = "error"; + response.Description = "目标地址格式不正确,请包含协议(http:// 或 https://)。"; + return response; + } + + if (uri.Scheme != "http" && uri.Scheme != "https") + { + response.Type = "error"; + response.Description = "仅支持 http:// 或 https:// 地址。"; + return response; + } + + response.IsHttps = uri.Scheme == "https"; + + // 构造探测 URL:使用 {origin}/api 而非根路径,避免因根路由缺失返回 404 + var probeUri = new Uri(uri, "/api"); + + try + { + var client = _httpClientFactory.CreateClient("ProbeClient"); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + using var request = new HttpRequestMessage(HttpMethod.Get, probeUri); + request.Headers.UserAgent.ParseAdd("HuaTodo-Probe/1.0"); + + using var httpResponse = await client.SendAsync(request, cts.Token); + response.IsReachable = true; + response.HttpStatus = (int)httpResponse.StatusCode; + + if (httpResponse.IsSuccessStatusCode) + { + if (!response.IsHttps) + { + response.Type = "warn"; + response.Title = "可达(建议 HTTPS)"; + response.Description = $"服务器响应 HTTP {response.HttpStatus},当前使用明文传输,建议切换为 HTTPS。"; + } + else + { + response.Type = "success"; + response.Title = "可达"; + response.Description = $"服务器响应 HTTP {response.HttpStatus},连接正常,可继续登录。"; + } + } + else + { + // 非 2xx 也说明服务器可达,仅作为提示 + if (!response.IsHttps) + { + response.Type = "warn"; + response.Title = "可达(建议 HTTPS)"; + response.Description = $"服务器响应 HTTP {response.HttpStatus}(非成功状态码),当前使用明文传输,建议切换为 HTTPS。"; + } + else + { + response.Type = "success"; + response.Title = "可达"; + response.Description = $"服务器响应 HTTP {response.HttpStatus}(非成功状态码),但连接正常,可继续登录。"; + } + } + } + catch (OperationCanceledException) + { + response.IsReachable = false; + response.Type = response.IsHttps ? "error" : "warn"; + response.Title = "存在风险"; + response.Description = "探测超时(网络不可达或目标无响应)。"; + } + catch (HttpRequestException ex) + { + response.IsReachable = false; + response.Type = response.IsHttps ? "error" : "warn"; + response.Title = "存在风险"; + response.Description = response.IsHttps + ? $"探测失败:{ex.Message}" + : $"探测失败:{ex.Message}。请检查地址是否可达,或改用 HTTPS。"; + } + catch (Exception) + { + response.IsReachable = false; + response.Type = "error"; + response.Description = "探测过程中发生未知错误。"; + } + + return response; + } + +} diff --git a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs b/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs index 9a0bf29..763d0d4 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/CloudTaskSyncService.cs @@ -64,45 +64,118 @@ public class CloudTaskSyncService if (request.Upserts.Count > 0) { - foreach (var upsert in request.Upserts) + // 去重:同一 Id 保留最后一条(后发送覆盖先发送) + var uniqueUpserts = request.Upserts + .GroupBy(u => u.Id) + .Select(g => g.Last()) + .Where(u => !string.IsNullOrWhiteSpace(u.Title)) + .ToList(); + + // 预先查询该用户所有已存在的任务 ID + var existingIds = await _dbContext.Tasks + .Where(t => t.UserId == userId) + .Select(t => t.Id) + .ToHashSetAsync(cancellationToken); + + // 记录:客户端 Id → 新创建的实体(用于 ParentTaskId 重映射) + var clientIdToEntity = new Dictionary(); + + // 第一遍:处理根任务(ParentTaskId == null) + foreach (var upsert in uniqueUpserts.Where(u => !u.ParentTaskId.HasValue)) { - if (string.IsNullOrWhiteSpace(upsert.Title)) - { - continue; - } + TaskEntity? entity = null; - if (upsert.Id.HasValue) + if (upsert.Id.HasValue && existingIds.Contains(upsert.Id.Value)) { - var existing = await _dbContext.Tasks + // 更新已存在任务 + entity = await _dbContext.Tasks .FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken); - - if (existing != null) + if (entity != null) { - existing.Title = upsert.Title.Trim(); - existing.Priority = upsert.Priority; - existing.IsCompleted = upsert.IsCompleted; - existing.ParentTaskId = upsert.ParentTaskId; - existing.UpdatedAt = DateTime.UtcNow; - continue; + entity.Title = upsert.Title.Trim(); + entity.Priority = upsert.Priority; + entity.IsCompleted = upsert.IsCompleted; + entity.ParentTaskId = null; + entity.UpdatedAt = DateTime.UtcNow; } } - var entity = new TaskEntity + if (entity == null) { - UserId = userId, - Title = upsert.Title.Trim(), - Priority = upsert.Priority, - IsCompleted = upsert.IsCompleted, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - ParentTaskId = upsert.ParentTaskId - }; + // 新建任务;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); - _dbContext.Tasks.Add(entity); + if (upsert.Id.HasValue) + { + clientIdToEntity[upsert.Id.Value] = entity; + } + } } - } - await _dbContext.SaveChangesAsync(cancellationToken); + // 保存根任务,获取服务器分配的 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 { diff --git a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs b/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs index 61acf0b..806d6e5 100644 --- a/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs +++ b/src/Hua.Todo.Application/CloudSync/Services/SecurityPolicyService.cs @@ -37,8 +37,6 @@ public class SecurityPolicyService { AllowPersist = policy?.AllowPersist ?? true, AllowSync = policy?.AllowSync ?? true, - RequireSecondFactorFor = new List { "sync:write", "policy:write" }, - SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30, IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false }; } @@ -49,11 +47,10 @@ public class SecurityPolicyService /// 用户 ID。 /// 是否允许落盘。 /// 是否允许同步。 - /// 二次认证有效期。 /// 是否仅限受信任设备。 /// 取消令牌。 /// 更新后的策略 DTO。 - public async Task UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, int secondFactorExpiryMinutes, bool isTrustedDeviceOnly, CancellationToken cancellationToken) + public async Task UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, bool isTrustedDeviceOnly, CancellationToken cancellationToken) { var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); if (policy == null) @@ -64,7 +61,6 @@ public class SecurityPolicyService UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync, - SecondFactorExpiryMinutes = secondFactorExpiryMinutes, IsTrustedDeviceOnly = isTrustedDeviceOnly }; _dbContext.SecurityPolicies.Add(policy); @@ -73,7 +69,6 @@ public class SecurityPolicyService { policy.AllowPersist = allowPersist; policy.AllowSync = allowSync; - policy.SecondFactorExpiryMinutes = secondFactorExpiryMinutes; policy.IsTrustedDeviceOnly = isTrustedDeviceOnly; } diff --git a/src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs b/src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs index 3adf2b3..c7a6b70 100644 --- a/src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs +++ b/src/Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs @@ -68,6 +68,15 @@ public class DynamicApiMiddleware var path = context.Request.Path.Value ?? string.Empty; var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + // 处理 /api 根路径探测请求:返回 200 表示服务在线 + if (segments.Length == 1 && segments[0] == "api") + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = 200; + await context.Response.WriteAsync("{\"status\":\"ok\"}"); + return; + } + if (segments.Length < 2 || segments[0] != "api") { await _next(context); diff --git a/src/Hua.Todo.Host/wwwroot/admin/index.html b/src/Hua.Todo.Host/wwwroot/admin/index.html index b778db9..a247b09 100644 --- a/src/Hua.Todo.Host/wwwroot/admin/index.html +++ b/src/Hua.Todo.Host/wwwroot/admin/index.html @@ -104,11 +104,6 @@ - - - diff --git a/src/Hua.Todo.Maui/appsettings.json b/src/Hua.Todo.Maui/appsettings.json index 6310f91..842ac5f 100644 --- a/src/Hua.Todo.Maui/appsettings.json +++ b/src/Hua.Todo.Maui/appsettings.json @@ -1,10 +1,10 @@ { "WebServer": { "Port": 5057, - "IsUsingStatic": true, + "IsUsingStatic": false, "ConnectionString": "", "HostUrl": "http://localhost:5057", - "ForEndUrl": "http://localhost:5057" + "ForEndUrl": "http://localhost:5174" }, "Development": { }, diff --git a/src/Hua.Todo.Web/.env.development b/src/Hua.Todo.Web/.env.development new file mode 100644 index 0000000..089aacd --- /dev/null +++ b/src/Hua.Todo.Web/.env.development @@ -0,0 +1,2 @@ +# Dev 模式:API 与云同步共用本地后端代理目标 +VITE_API_TARGET=http://localhost:5173 diff --git a/src/Hua.Todo.Web/.env.maui b/src/Hua.Todo.Web/.env.maui new file mode 100644 index 0000000..5136ea6 --- /dev/null +++ b/src/Hua.Todo.Web/.env.maui @@ -0,0 +1,2 @@ +# MAUI 构建:云同步远端地址 +VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com diff --git a/src/Hua.Todo.Web/.env.production b/src/Hua.Todo.Web/.env.production new file mode 100644 index 0000000..555f69f --- /dev/null +++ b/src/Hua.Todo.Web/.env.production @@ -0,0 +1,2 @@ +# Production 构建:云同步远端地址(proxy 在 build 时无效,此变量可由其他构建逻辑使用) +VITE_CLOUD_SYNC_URL=https://api.hua-todo.example.com diff --git a/src/Hua.Todo.Web/src/api/cloudClient.ts b/src/Hua.Todo.Web/src/api/cloudClient.ts index dcdb464..e3f4229 100644 --- a/src/Hua.Todo.Web/src/api/cloudClient.ts +++ b/src/Hua.Todo.Web/src/api/cloudClient.ts @@ -1,6 +1,5 @@ import axios from 'axios'; import CloudSyncStorage from '../services/cloudSyncStorage'; -import { cloudSyncState } from '../services/cloudSyncState'; const showNotification = (message: string, type: 'error' | 'success' = 'error') => { if ((window as any).showToast) { @@ -33,10 +32,17 @@ const cloudClient = axios.create({ }); cloudClient.interceptors.request.use((config) => { - const { serverUrl } = CloudSyncStorage.loadSettings(); - if (serverUrl) { - config.baseURL = serverUrl; + // 根据运行模式决定 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 使用浏览器默认同源 const session = CloudSyncStorage.loadSession(); if (session?.accessToken) { @@ -53,20 +59,23 @@ cloudClient.interceptors.response.use( let errorMessage = '网络错误,请稍后再试'; const status = error?.response?.status as number | undefined; - const errorCode = error?.response?.data?.code as string | undefined; + const data = error?.response?.data as any; + const errorCode: string | undefined = data?.code ?? data?.Code; - if (status === 403 && errorCode === 'SECOND_FACTOR_REQUIRED') { - // 捕获“需要二次认证”错误,触发二次认证弹窗,并中断当前请求(不弹普通错误提示) - cloudSyncState.isSteppingUp = true; - // 注意:这里我们暂不自动重试,让用户在弹窗输入密码后再手动触发(或由 UI 层处理重试) + if (status === 403 && errorCode === 'FORBIDDEN') { + // 缺少同步权限(例如 AllowSync = false):提示用户检查权限配置 + showNotification('云同步权限不足:请确认安全策略中已启用 AllowSync', 'error'); return Promise.reject(error); } if (status === 401 || status === 403) { - CloudSyncStorage.clearSession(); - requestCloudSyncReLogin(); - errorMessage = status === 401 ? '云同步会话已过期,请重新登录' : '云同步权限不足,请重新登录或联系管理员'; - showNotification(errorMessage, 'error'); + if (status === 401) { + CloudSyncStorage.clearSession(); + requestCloudSyncReLogin(); + showNotification('云同步会话已过期,请重新登录', 'error'); + } else { + showNotification(`云同步请求被拒绝(${errorCode || status})`, 'error'); + } return Promise.reject(error); } diff --git a/src/Hua.Todo.Web/src/api/cloudSync.ts b/src/Hua.Todo.Web/src/api/cloudSync.ts index 01afda7..a33104e 100644 --- a/src/Hua.Todo.Web/src/api/cloudSync.ts +++ b/src/Hua.Todo.Web/src/api/cloudSync.ts @@ -25,18 +25,49 @@ export interface CloudTaskItem { parentTaskId?: number | null; } +/** + * 同步请求中的任务 Upsert DTO(用于新增或更新任务)。 + * 注意:id 为 null/undefined 表示新建任务,由服务端分配 ID。 + */ +export interface CloudTaskUpsert { + id?: number | null; + title: string; + priority: 0 | 1 | 2; + isCompleted: boolean; + parentTaskId?: number | null; +} + +/** + * 同步请求体(包含新增/更新和删除列表)。 + */ +export interface CloudSyncRequest { + upserts: CloudTaskUpsert[]; + deletes: number[]; +} + +/** + * 同步响应体(返回服务端最新全量任务列表)。 + */ +export interface CloudSyncResponse { + serverTimeUtc: string; + tasks: CloudTaskItem[]; +} + export interface SecurityPolicyResponse { allowPersist: boolean; allowSync: boolean; - requireSecondFactorFor: string[]; } -export interface StepUpRequest { - password: string; -} - -export interface StepUpResponse { - stepUpExpiresAtUtc: string; +/** + * 服务端探测响应。 + */ +export interface ProbeServerUrlResponse { + isReachable: boolean; + httpStatus: number | null; + isHttps: boolean; + title: string; + description: string; + type: 'success' | 'warn' | 'error'; } /** @@ -70,6 +101,37 @@ export const buildTaskTreeFromCloudItems = (items: CloudTaskItem[]): Task[] => { return roots; }; +/** + * 把前端的任务树结构扁平化为 CloudTaskUpsert 列表,用于发送到云端。 + * - id > 0 表示已同步到云端的任务(保留 id 用于更新) + * - id <= 0 或未设置表示本地新建任务(id 置为 null,由服务端分配新 ID) + * - 使用 seen 集合去重:同一任务(按 id 或 hash)只发送一次 + */ +export const flattenTasksToCloudUpserts = (tasks: Task[]): CloudTaskUpsert[] => { + const result: CloudTaskUpsert[] = []; + const seenIds = new Set(); + const walk = (items: Task[]) => { + for (const task of items) { + const id = task.id > 0 ? 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, + isCompleted: Boolean(task.isCompleted), + parentTaskId: typeof task.parentTaskId === 'number' && task.parentTaskId > 0 ? task.parentTaskId : null, + }); + if (task.subTasks && task.subTasks.length > 0) { + walk(task.subTasks); + } + } + }; + walk(tasks); + return result; +}; + /** * 云同步 API(v1.2.0 最小闭环:登录 + 拉取任务)。 */ @@ -93,9 +155,14 @@ export const cloudSyncApi = { }, /** - * 清空云同步会话(登出)。 + * 登出:通知服务端撤销会话并清空本地 Token。 */ - logout(): void { + async logout(): Promise { + try { + await cloudClient.post('/auth/logout'); + } catch { + // 即使服务端请求失败也清空本地,避免残留无效 Token + } CloudSyncStorage.clearSession(); }, @@ -108,6 +175,22 @@ export const cloudSyncApi = { return buildTaskTreeFromCloudItems(items); }, + /** + * 同步本地任务到云端(批量 upsert + delete),并返回云端最新任务列表和服务器时间。 + * 调用路径:POST /sync/ ,请求体为 { upserts, deletes },响应为 { serverTimeUtc, tasks }。 + * 注意:该端点需要已登录 + 二次认证(step-up)。 + */ + async syncTasks(localTasks: Task[], deleteIds: number[] = []): Promise<{ tasks: Task[]; serverTimeUtc: string | undefined }> { + const upserts = flattenTasksToCloudUpserts(localTasks); + const body: CloudSyncRequest = { upserts, deletes: deleteIds }; + const response = await cloudClient.post('/sync/', body); + const items = Array.isArray(response.data?.tasks) ? response.data.tasks : []; + return { + tasks: buildTaskTreeFromCloudItems(items), + serverTimeUtc: response.data?.serverTimeUtc, + }; + }, + /** * 获取当前用户的安全策略。 */ @@ -117,10 +200,11 @@ export const cloudSyncApi = { }, /** - * 执行二次认证(Step-up)。 + * 通过 Host 服务端探测外部服务端可达性。 + * 探测请求从 Host 发起,避免浏览器直连的 CORS/证书/网络限制。 */ - async stepUp(password: string): Promise { - const response = await cloudClient.post('/auth/step-up', { password }); + async probeServerUrl(targetUrl: string): Promise { + const response = await cloudClient.post('/cloud-sync/probe', { targetUrl }); return response.data; }, }; diff --git a/src/Hua.Todo.Web/src/api/tasks.ts b/src/Hua.Todo.Web/src/api/tasks.ts index 2c7cbc6..c4f0fc6 100644 --- a/src/Hua.Todo.Web/src/api/tasks.ts +++ b/src/Hua.Todo.Web/src/api/tasks.ts @@ -12,9 +12,10 @@ export const taskApi = { async getTasks(completed?: boolean): Promise> { if (!LocalStorageService.isOnline()) { const localTasks = LocalStorageService.loadTasks(); + LocalStorageService.syncTaskIdCounter(localTasks); return { success: true, - data: completed !== undefined + data: completed !== undefined ? localTasks.filter(t => t.isCompleted === completed) : localTasks, message: '从本地加载任务' @@ -50,6 +51,8 @@ export const taskApi = { syncStatus.isOnline = true; syncStatus.pendingChanges = 0; LocalStorageService.saveSyncStatus(syncStatus); + // 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突 + LocalStorageService.syncTaskIdCounter(apiResponse.data); } return apiResponse; @@ -100,7 +103,7 @@ export const taskApi = { */ async createTask(dto: CreateTaskDto): Promise> { const newTask: Task = { - id: Date.now(), + id: LocalStorageService.getNextTaskId(), title: dto.title, priority: dto.priority, isCompleted: false, @@ -114,11 +117,11 @@ export const taskApi = { const localTasks = LocalStorageService.loadTasks(); localTasks.unshift(newTask); LocalStorageService.saveTasks(localTasks); - + const syncStatus = LocalStorageService.loadSyncStatus(); syncStatus.pendingChanges++; LocalStorageService.saveSyncStatus(syncStatus); - + return { success: true, data: newTask, @@ -127,7 +130,7 @@ export const taskApi = { } const response = await apiClient.post>('/task', dto); - + let apiResponse: ApiResponse; if (response.data && 'id' in response.data) { // 后端直接返回任务对象 @@ -140,7 +143,7 @@ export const taskApi = { // 后端返回包装的响应对象 apiResponse = response.data as ApiResponse; } - + if (apiResponse.success && apiResponse.data) { apiResponse.data = normalizeTask(apiResponse.data); const localTasks = LocalStorageService.loadTasks(); @@ -151,8 +154,10 @@ export const taskApi = { localTasks.unshift(apiResponse.data); } LocalStorageService.saveTasks(localTasks); + // 用后端返回的真实 ID 同步计数器,保证后续新建任务 ID 始终大于已有任务 + LocalStorageService.syncTaskIdCounter(localTasks); } - + return apiResponse; }, @@ -386,7 +391,9 @@ export const taskApi = { syncStatus.isOnline = true; syncStatus.pendingChanges = 0; LocalStorageService.saveSyncStatus(syncStatus); - + // 同步 nextTaskId 计数器,确保后续新建任务的 ID 不会与已有 ID 冲突 + LocalStorageService.syncTaskIdCounter(apiResponse.data); + return { success: true, data: apiResponse.data, diff --git a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue index 3e9f9df..20d4103 100644 --- a/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue +++ b/src/Hua.Todo.Web/src/components/CloudSyncSettingsDialog.vue @@ -11,10 +11,10 @@
同步开关
- 启用后,主界面将以云端任务为准并进入只读展示;本地编辑/新增将在后续版本补齐。 + 启用后,任务将与云端同步,支持双向同步功能。
@@ -47,6 +47,29 @@
已登录:{{ sessionSummary }}
+ + +
+
+ 连接状态: + + {{ syncStatus.isOnline ? '在线' : '离线' }} + +
+
+ 上次同步: + + {{ syncStatus.lastSyncTime > 0 ? formatSyncTime(syncStatus.lastSyncTime) : '从未同步' }} + +
+
+ 待同步: + + {{ syncStatus.pendingChanges }} 项变更 + +
+
+
落盘策略: @@ -63,8 +86,9 @@
-
@@ -100,99 +124,186 @@ - - -
-
-
-

🔐 二次认证

-
-
-
当前操作属于高风险操作,请验证您的登录密码。
-
- -
-
{{ stepUpError }}
-
- -
-
+