feat: 完成v1.3.0架构对齐与Web端根路径托管全量开发

- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐
- 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转
- 实现种子数据初始化:添加默认管理员账号自动创建逻辑
- 优化前端代理与构建:新增Vite代理配置与自动构建Target
- 补全测试与文档:新增集成测试,更新开发与部署文档
This commit is contained in:
ShaoHua
2026-08-08 07:33:44 +08:00
parent 4fe0b5a963
commit 3213d4c718
32 changed files with 1937 additions and 86 deletions
+88 -28
View File
@@ -10,45 +10,101 @@
| 项目 | 类型 | 职责 | 平台 | | 项目 | 类型 | 职责 | 平台 |
|---|---|---|---| |---|---|---|---|
| [Hua.Todo.Core](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Core) | 类库 | 领域实体、仓储接口(`TaskEntity`、`UserEntity`、`SecurityPolicyEntity`、`AuditLogEntity`、`ITaskRepository` 等) | netstandard / net | | [Hua.Todo.Core](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Core) | 类库 | 领域实体、仓储接口(`TaskEntity`、`UserEntity`、`SecurityPolicyEntity`、`AuditLogEntity`、`ITaskRepository` 等) | netstandard / net |
| [Hua.Todo.Application](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application) | 类库(共享层) | EF Core `TodoDbContext`、`TaskService`/`TaskRepository`、动态 API(`DynamicApi/`)、云同步(`CloudSync/`)、迁移 | net | | [Hua.Todo.Application](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application) | 类库(共享层) | EF Core `TodoDbContext`、`TaskService`/`TaskRepository`、CloudSync 业务服务(`Application/Services/CloudSync/`)、动态 API 属性层、迁移 | net |
| [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host) | ASP.NET 服务端 | 独立服务端宿主,承载本地动态 API + 云同步端点 + Admin 管理后台静态资源 | Windows/Linux 等服务器 | | [Hua.Todo.HttpApi.AspNetCore](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.HttpApi.AspNetCore) | 类库(ASP.NET Core 专属 HTTP 层) | 承载依赖 ASP.NET Core 的实现:DynamicApiMiddleware、Mcp Server 端点、云同步服务端 DI(SessionAuthenticationHandler、CloudSyncServerService) | net(依赖 `Microsoft.AspNetCore.App` 共享框架) |
| [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host) | ASP.NET 服务端 | **Host 双角色统一部署**:(1) Web 端前端/后端同源部署(承载前端静态资源 + Dynamic API + Swagger + MCP);(2) 云同步权威服务端(供 MAUI/Avalonia 的 CloudSyncProxy 转发);不区分模式 | Windows/Linux 等服务器 |
| [Hua.Todo.Maui](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Maui) | MAUI 客户端 | Windows / macOS / iOS / Android 入口;内嵌 WebServer + WebView 承载 Vue 前端 | 多端 | | [Hua.Todo.Maui](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Maui) | MAUI 客户端 | Windows / macOS / iOS / Android 入口;内嵌 WebServer + WebView 承载 Vue 前端 | 多端 |
| [Hua.Todo.Avalonia](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Avalonia) | Avalonia 客户端 | Linux / Windows 桌面入口;内嵌 WebServer + `WebView.Avalonia` 承载 Vue 前端 | 桌面(含 Linux) | | [Hua.Todo.Avalonia](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Avalonia) | Avalonia 客户端 | Linux / Windows 桌面入口;内嵌 WebServer + `WebView.Avalonia` 承载 Vue 前端 | 桌面(含 Linux) |
| [Hua.Todo.Web](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Web) | Vite + Vue 3 前端 | 同一份前端构建产物,被多个宿主以静态资源形式承载 | 浏览器/WebView | | [Hua.Todo.Web](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Web) | Vite + Vue 3 前端 | 同一份前端构建产物,被多个宿主以静态资源形式承载 | 浏览器/WebView |
## 二、依赖方向(必须保持单向) ## 二、依赖方向(必须保持单向)
> 本图为 2026-08-08 v1.3.0 工单 05 更新版(代码对齐时间戳)。后续修改架构章节时,必须更新本标记并走 Glob/Grep 校验(详见 v1.3.0 00-工单总览 §5「架构文档 ↔ 代码一致性强制策略」)。
``` ```
Hua.Todo.Core Hua.Todo.Core (无依赖)
↑ ↑ 单向引用
Hua.Todo.Application Hua.Todo.Application (依赖 Core)
↑ ↑ 单向引用
├── Hua.Todo.Host (服务端注册 AddCloudSyncServer + 动态 API) Hua.Todo.HttpApi.AspNetCore (依赖 Application;依赖 Microsoft.AspNetCore.App 共享框架)
├── Hua.Todo.Maui (客户端,仅注册 AddApplicationServices) ↑ 单向引用
└── Hua.Todo.Avalonia (客户端,仅注册 AddApplicationServices) ├── Hua.Todo.Host (注册 AddApplicationServices + AddCloudSyncServer + AddMcpServerServices)
├── Hua.Todo.Maui (仅引用 Application + HttpApi.AspNetCore 的 CloudSyncProxy 子模块;不引用 AddCloudSyncServer)
└── Hua.Todo.Avalonia (同上,仅嵌入式 + CloudSyncProxy)
Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主) Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主)
``` ```
- **禁止反向依赖**:Core 不得引用 Application;Application 不得引用任何宿主项目。 - **禁止反向依赖**:Core 不得引用 Application;Application 不得引用 HttpApi.AspNetCore / 任何宿主;HttpApi.AspNetCore 不得引用宿主项目。
- **客户端不暴露云同步端点**:MAUI / Avalonia 只调用 `AddApplicationServices()`,不调用 `AddCloudSyncServer()`,详见 [docs/project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md](../../../docs/project/研发工单-v1.2.0/08-cloud_sync_refactor_plan.md)。 - **MAUI / Avalonia 不得注册 AddCloudSyncServer**:只注册 `AddApplicationServices()` + `AddCloudSyncProxy()`,权威云同步端点只在 Host 暴露。
- **MAUI / Avalonia 跨层引用边界**:只引用 HttpApi.AspNetCore 中与嵌入式相关的类型(CloudSyncProxyMiddleware、CloudSyncProxySettings 等),不引用 CloudSyncServerService 等 Host-only DI 扩展(实际通过命名空间自然隔离)。
## 三、两种运行模式 ## 三、两种运行模式
### 模式 A:嵌入式(MAUI / Avalonia + WebView) ### 端口对照表(全局统一,禁止硬编码歧义值)
- 启动内嵌 Kestrel WebServer(默认端口 5057),托管 Vue 前端的静态产物(`wwwroot/`)+ 本地 `/api/*`
- WebView 加载 `HostUrl`(生产)或 `ForEndUrl`(开发,例如 Vite dev server)
- 注入:`window.__API_BASE_URL__ = "${HostUrl}/api"`、`window.mauiInterop`
- 默认 SQLite 路径:`LocalApplicationData/Hua.Todo/Hua.Todo.db`(避免安装目录无写权限)
- 不暴露云同步端点;登录/同步走外部 Host
### 模式 B:独立服务端(Hua.Todo.Host) | 端口 | 用途 | 所属模式 | 说明 |
- 独立部署的 ASP.NET 应用,端口 `:5173`(Vite proxy 目标) |---|---|---|---|
- 同时注册 `AddApplicationServices()` + `AddCloudSyncServer()`,对外提供: | `:5057` | 嵌入式 Kestrel WebServer(HTTP) | 嵌入式 | MAUI / Avalonia 内嵌;Vite proxy 默认 target |
- `/api/*`:本地任务 API(动态 API) | `:5173` | Web 端 ASP.NET(HTTP) | Web 端 | 前端云同步默认填写的服务器地址;Host `Program.cs.UseUrls` 显式绑定 |
- `/auth/*`、`/tasks/*`、`/sync`、`/security/policy`、`/cloud-sync/probe`:云同步端点 | `:7055` | Web 端 ASP.NET(HTTPS) | Web 端 | 开发环境可选;生产由反向代理(Nginx/Caddy)统一 TLS 终止 |
- `/admin/*`:管理后台前端静态资源 | `:5174` | Vite dev server(开发) | 两者均可 | WebView / 浏览器开发调试入口;`.env.development` 使用 |
- 数据库:`src/Hua.Todo.Host/Hua.Todo.db`(开发/测试用)
### 模式 A:嵌入式(MAUI / Avalonia + WebView)
> 桌面/移动原生客户端,**数据先存本地 SQLite(本地缓存)**,再按需与 Web 端同步。
**请求链路(前端 → 本地 → 远端)**:
```
WebView(Hua.Todo.Web)
├─ 本地 Todo 操作(增删改查) → 同源 /api/task* 等动态 API
│ → EmbeddedWebServerService(:5057)
│ → UseDynamicApi
│ → ITaskService / TaskRepository
│ → 本地 SQLite(Hua.Todo.db)
└─ 云同步操作(探测 / 登录 / 拉取 / 推送 / 策略)
→ 同源 /api/auth、/api/tasks、/api/cloud-sync/*、/api/security/*
→ EmbeddedWebServerService(:5057)
→ UseCloudSyncProxy(CloudSyncProxyMiddleware)
→ 后端 HttpClient 转发到「用户配置的 Web 端地址」(例 http://localhost:5173)
→ Web 端 Hua.Todo.Host 的动态 API 做鉴权与同步
```
**关键规则**:
- 启动内嵌 Kestrel WebServer(端口 `:5057`),生产托管 Vue 前端静态产物(`wwwroot/`),开发加载 Vite dev server(`ForEndUrl=http://localhost:5174`)
- 注入:`window.__API_BASE_URL__ = "${HostUrl}/api"`(HostUrl=`http://localhost:5057`)、`window.mauiInterop`
- DI 只注册 `AddApplicationServices()` + `AddCloudSyncProxy()`,**不注册 `AddCloudSyncServer()`**:嵌入式端不做用户管理、不暴露云同步的权威端点
- 默认 SQLite 路径:`LocalApplicationData/Hua.Todo/Hua.Todo.db`(避免安装目录无写权限)
- 本地用户 ID 固定为 `TodoUserIds.LocalUserId = "local"`,与云端用户隔离
### 模式 B:Web 端(Hua.Todo.Host,部署入口)= **Host 双角色统一部署**
> ⚠️ Host 部署时不区分模式:
> - **对外角色**:用户浏览器直接访问 Host(`http://host-address:5173/`)→ 即"Web 端前端 + 后端同源部署"
> - **对内角色**:同时承担"云同步权威服务端"职责,接受 MAUI/Avalonia 嵌入式客户端通过 CloudSyncProxy 转发 `/api/*` 过来的请求
>
> 因此 `Hua.Todo.Host/Program.cs` 必须同时注册:`AddApplicationServices` + `AddCloudSyncServer` + `AddMcpServerServices`。
>
> 独立部署的 ASP.NET 服务端,既是**后端接口服务**,也是**前端静态站点托管**,部署产物是单个 `Hua.Todo.Host` 进程。
**端点划分(四个逻辑入口,同一进程)**:
| URL 前缀 | 内容 | 来源 |
|---|---|---|
| `/`(根路径及所有 SPA 路由) | **Hua.Todo.Web 用户前端**(登录后 Todo 列表) | `src/Hua.Todo.Web` 的 `dist/` 构建产物复制到 `Hua.Todo.Host/wwwroot/`;若缺失则由 `MinimalBootstrapPageMiddleware` 返回最小引导页(中文错误提示 + 构建命令,不 404) |
| `/api/*` | **动态 API**(本地+云同步共用的一套接口) | `Hua.Todo.Application/DynamicApi` 扫描所有 `IDynamicApiService` 自动暴露(task / auth / tasks / security / cloud-sync / admin / voice)。权限检查时,浏览器 HTML 直访 Accept:text/html → 302 跳 `/`(前端接管登录弹窗);其他 API 请求(JSON)→ 401/403 JSON 错误 |
| `/mcp` | **MCP Server 端点**(Streamable HTTP) | `McpEndpointExtensions.cs` 映射,供 MCP 客户端调用 |
| `/swagger` + `/admin/*` | **Swagger UI** + **管理后台前端**(用户管理、审计、策略) | Swagger:开发环境启用;Admin:`Hua.Todo.Host/wwwroot/admin/`(当前占位,后续 Vue 3 构建产物填入) |
**关键规则**:
- 同时注册 `AddApplicationServices()` + `AddCloudSyncServer()`(后者额外注册 `IPasswordHasher<UserEntity>`、云同步动态 API、安全策略校验器等)
- 中间件顺序必须严格遵守(见 `00-工单总览.md` §5):Swagger → MinimalBootstrapPageMiddleware → UseDefaultFiles/UseStaticFiles → CORS → Authentication → Authorization → UseDynamicApi → MapMcpServer → MapFallbackToFile → Run
- 端口:HTTP `:5173` + HTTPS `:7055`,`Program.cs` 显式 `UseUrls` 绑定
- 开发环境**不启用 `UseHttpsRedirection`**(由反向代理统一做 TLS 终止),避免前端 axios 把 307 当错误
- 数据库:开发/测试使用 `src/Hua.Todo.Host/Hua.Todo.db`(SQLite);生产可切换其他后端
## 四、跨平台原则 ## 四、跨平台原则
@@ -60,11 +116,15 @@ Hua.Todo.Web (无 .NET 依赖;通过 HTTP/同源调用上述任一宿主)
| 扩展点 | 位置 | | 扩展点 | 位置 |
|---|---| |---|---|
| DI 注册总入口 | [Hua.Todo.Application/ServiceCollectionExtensions.cs](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Application/ServiceCollectionExtensions.cs) | | DI 注册总入口 | `Hua.Todo.Application/Common/ServiceCollectionExtensions.cs`(`AddApplicationServices`) |
| 云同步 DI | `Hua.Todo.Application/CloudSync/CloudSyncServiceCollectionExtensions.cs` | | 云同步服务端 DI(Host 专用) | `Hua.Todo.HttpApi.AspNetCore/CloudSync/CloudSyncServiceCollectionExtensions.cs`(`AddCloudSyncServer`) |
| 云同步端点 | `Hua.Todo.Application/CloudSync/CloudSyncEndpointExtensions.cs` | | 云同步代理 DI / 中间件(嵌入式) | `Hua.Todo.Application/Services/CloudSync/Services/CloudSyncProxyService.cs`(`AddCloudSyncProxy` / `UseCloudSyncProxy`) |
| 动态 API 中间件 | `Hua.Todo.Application/DynamicApi/DynamicApiMiddleware.cs` | | 云同步代理设置端点(嵌入式) | `Hua.Todo.Application/Services/CloudSync/Services/ICloudSyncProxySettingsService`(继承 `IDynamicApiService`,由 DynamicApi 自动暴露;仅在 AddCloudSyncProxy 时注册 DI) |
| 云同步接口(auth/tasks/security/admin/cloud-sync probe) | `Hua.Todo.Application/Services/CloudSync/Services/ICloud*Service` 实现 `IDynamicApiService`,由 DynamicApi 自动暴露 |
| 动态 API 中间件 + 路由/权限 | `Hua.Todo.HttpApi.AspNetCore/DynamicApi/DynamicApiMiddleware.cs`(含 `DynamicApiRouteAttribute` / `RequirePermissionAttribute`) |
| MCP Server(工具描述符自动生成 + 端点) | `Hua.Todo.HttpApi.AspNetCore/Mcp/DynamicMcpToolExtensions.cs` + `McpServiceCollectionExtensions.cs`(DI 注册 `AddMcpServerServices`) + `McpEndpointExtensions.cs`(端点 `MapMcpServer`) |
| 嵌入式 WebServer | `Hua.Todo.{Maui,Avalonia}/Services/EmbeddedWebServerService.cs` | | 嵌入式 WebServer | `Hua.Todo.{Maui,Avalonia}/Services/EmbeddedWebServerService.cs` |
| 种子数据初始化器(Web 端 DB Migrate 后) | `Hua.Todo.Application/Services/DataSeeder/IDataSeeder`(仅在 `AddCloudSyncServer` 时注册) |
| 全局快捷键平台实现 | `Hua.Todo.{Maui,Avalonia}/Services/Platforms/*GlobalHotKeyService.cs` | | 全局快捷键平台实现 | `Hua.Todo.{Maui,Avalonia}/Services/Platforms/*GlobalHotKeyService.cs` |
## 六、版本统一策略 ## 六、版本统一策略
+14 -2
View File
@@ -9,9 +9,9 @@
--- ---
## 一、当前活跃版本 ## 一、当前活跃版本(2026-08-08 快照)
- **进行中版本**:v1.2.0(收尾中)、v1.3.0(规划中) - **进行中版本**:v1.2.0(收尾中)、v1.3.0(开发中)
- **v1.2.0 研发工单总览**:[docs/project/研发工单-v1.2.0/00-工单总览.md](../../../docs/project/研发工单-v1.2.0/00-工单总览.md) - **v1.2.0 研发工单总览**:[docs/project/研发工单-v1.2.0/00-工单总览.md](../../../docs/project/研发工单-v1.2.0/00-工单总览.md)
- **v1.3.0 研发工单总览**:[docs/project/研发工单-v1.3.0/00-工单总览.md](../../../docs/project/研发工单-v1.3.0/00-工单总览.md) - **v1.3.0 研发工单总览**:[docs/project/研发工单-v1.3.0/00-工单总览.md](../../../docs/project/研发工单-v1.3.0/00-工单总览.md)
- **PRD**:[docs/project/产品需求文档-1.2.0.md](../../../docs/project/产品需求文档-1.2.0.md) - **PRD**:[docs/project/产品需求文档-1.2.0.md](../../../docs/project/产品需求文档-1.2.0.md)
@@ -31,6 +31,7 @@
| 07 - 文档同步与验收 | 进行中 | 进行中 | README/docs 已基本对齐;术语对照刚完成 | | 07 - 文档同步与验收 | 进行中 | 进行中 | README/docs 已基本对齐;术语对照刚完成 |
| 08 - cloud_sync 重构 | 已设计 | 待实现 | "同源 Host"方案,Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` | | 08 - cloud_sync 重构 | 已设计 | 待实现 | "同源 Host"方案,Vite proxy 补 `/auth` `/tasks` `/sync` `/security` `/cloud-sync` |
| 09 - CloudSync 同步策略改进 | 已实现 | 待验证 | TaskEntity 继承 ABP 基类;软删除修复(SaveChangesAsync);前端类型和 cloudSync.ts 已更新;新增 guid.ts | | 09 - CloudSync 同步策略改进 | 已实现 | 待验证 | TaskEntity 继承 ABP 基类;软删除修复(SaveChangesAsync);前端类型和 cloudSync.ts 已更新;新增 guid.ts |
| 10 - 数据库初始化默认管理员 | 待实现 | 待验证 | Host 服务端 DB Migrate 后自动创建默认管理员;仅 Host 模式;配置驱动 + 幂等 + 强制首登改密;作为 06.1 Bootstrap 自动前置项。详见 [10-数据库初始化默认管理员.md](../../../docs/project/研发工单-v1.2.0/10-数据库初始化默认管理员.md) |
## 三、v1.3.0 工单状态快照 ## 三、v1.3.0 工单状态快照
@@ -44,6 +45,8 @@
| 03-03 - AI 任务拆分服务 | 待开始 | 待验证 | 会议专用 LLM prompt + 批量创建子任务 | | 03-03 - AI 任务拆分服务 | 待开始 | 待验证 | 会议专用 LLM prompt + 批量创建子任务 |
| 03-04 | 任务建议与确认 UI | 已完成 | 待验证 | MeetingBreakdownDialog.vue + meeting.ts 拆分/确认 API;待集成到 TaskItem | | 03-04 | 任务建议与确认 UI | 已完成 | 待验证 | MeetingBreakdownDialog.vue + meeting.ts 拆分/确认 API;待集成到 TaskItem |
| 04 | 富文本描述、附件与外部链接 | 已完成 | 待验证 | Description 字段 + AttachmentEntity 模型 + 附件 CRUD API + 外部链接 + 平台文件打开器(Maui/Avalonia)+ 前端类型和 API 模块完善 + EF 迁移 + 19 个单元测试通过 | | 04 | 富文本描述、附件与外部链接 | 已完成 | 待验证 | Description 字段 + AttachmentEntity 模型 + 附件 CRUD API + 外部链接 + 平台文件打开器(Maui/Avalonia)+ 前端类型和 API 模块完善 + EF 迁移 + 19 个单元测试通过 |
| 05 | 架构文档与代码实际路径对齐 | 已完成 | 已验证 | 01-项目架构.md §1 补 HttpApi.AspNetCore 条目、§2 ASCII 依赖图加中间层、§5 修复 3 个扩展点路径;代码侧:抽离 AddCloudSyncServer 到 CloudSyncServiceCollectionExtensions.cs;设计修正:MapCloudSyncProxySettings 手动端点已废弃,由 ICloudSyncProxySettingsService(IDynamicApiService)自动暴露,清理 v1.3.0 00-工单总览 Host 中间件顺序末尾的 MapCloudSyncProxySettings;模式 B 中间件顺序硬约束文档化为 Swagger→StaticFiles→CORS→Authentication→Authorization→UseDynamicApi→MapMcpServer→MapFallbackToFile→Run |
| 06 | Web 端根路径托管前端 | 已完成 | 已验证(4 场景集成测试) | Host Program.cs 加 UseDefaultFiles/UseStaticFiles/MapFallbackToFile("index.html"),顺序严格对齐硬约束;Host.csproj 加 BuildHuaTodoWebWebRoot Target(增量 npm build + 双 wwwroot 拷贝 + Node 缺失 Warning + SkipBuildHuaTodoWebWebRoot 开关);docs/manual/06-开发环境与构建.md 新增「模式 B 双开」「1.4 BuildHuaTodoWebWebRoot 自动构建说明」「3.0 部署构建流程」三节;v1.3.0 00-工单总览补充 6 条 Given-When-Then 验收表;WebRootStaticFileTests.cs 4 个集成测试覆盖根路径、SPA fallback、/api 不覆盖、Swagger 不覆盖 |
## 四、关键临时决策 ## 四、关键临时决策
@@ -54,7 +57,10 @@
## 五、已知未完结事项 / 待办 ## 五、已知未完结事项 / 待办
- [x] v1.3.0 工单 05 - 架构文档与代码实际路径对齐:已完成(文档 + 代码抽离 + 中间件顺序约束文档化)
- [x] v1.3.0 工单 06 - Web 端根路径托管前端:已完成(Program.cs 三中间件 + Host.csproj BuildHuaTodoWebWebRoot Target + docs 双开指南 + 4 场景集成测试)
- [ ] 06 客户端"内存模式"在 `allowPersist=false` 时的端到端落盘清理(含 token、同步队列)尚未充分验证 - [ ] 06 客户端"内存模式"在 `allowPersist=false` 时的端到端落盘清理(含 token、同步队列)尚未充分验证
- [ ] 工单 10 - 数据库初始化默认管理员:待实现 `IDataSeeder`/`DataSeeder` + Host Program.cs 集成 + `AdminBootstrapOptions` 配置 + 测试先行(详见 [10-数据库初始化默认管理员.md](../../../docs/project/研发工单-v1.2.0/10-数据库初始化默认管理员.md))
- [ ] 06.1 设计中的 Admin 管理后台前端(位于 `Hua.Todo.Host/wwwroot/admin/`)当前仅有 `index.html` 占位,需 Vue 3 + Vite 实现 - [ ] 06.1 设计中的 Admin 管理后台前端(位于 `Hua.Todo.Host/wwwroot/admin/`)当前仅有 `index.html` 占位,需 Vue 3 + Vite 实现
- [ ] 08 同源 Host 改造:`cloudClient.ts` 的 baseURL 解耦、Vite proxy 端点补全 - [ ] 08 同源 Host 改造:`cloudClient.ts` 的 baseURL 解耦、Vite proxy 端点补全
- [ ] Linux Flatpak/AppImage 自包含产物在干净环境的实测验证(v1.2.0 验收 Linux 部分仍为"待验证") - [ ] Linux Flatpak/AppImage 自包含产物在干净环境的实测验证(v1.2.0 验收 Linux 部分仍为"待验证")
@@ -89,6 +95,12 @@
7. 清理测试项目中的 SkipCloudSync 排除 7. 清理测试项目中的 SkipCloudSync 排除
- **验证**:Application(net10.0 + net10.0-android) + Host + MAUI(Windows + Android) 全部 0 错误;152 个测试全部通过 - **验证**:Application(net10.0 + net10.0-android) + Host + MAUI(Windows + Android) 全部 0 错误;152 个测试全部通过
- [x] Host 双角色统一部署路由与鉴权流修补(2026-08-08,对应用户反馈三条问题):
- **问题 1:根路径 404** → 修复:`Hua.Todo.Host/Program.cs` 新增 `MinimalBootstrapPageMiddleware`,前端 index.html 缺失时,访问 `/` 与 SPA 路由返回中文引导页(含三种修复方式 + 可继续使用的 API/Swagger/MCP 入口),不再 404
- **问题 2:没权限应跳登录页** → 修复:`DynamicApiMiddleware` 新增 `WriteUnauthorizedResponse/WriteForbiddenResponse/IsBrowserHtmlRequest`,GET/HEAD + Accept:text/html 且非 XMLHttpRequest → 302 Location="/" 跳回 Host 前端 SPA,SPA 判断未登录 → 弹 `CloudSyncSettingsDialog`;Accept:json 或非 GET/HEAD → 保持原 401/403 JSON(不破坏 axios/fetch 拦截器)
- **问题 3:Host 部署不区分模式** → 修复:`01-项目架构.md` 模式 B 章节重写为"Host 双角色统一部署 = (1) Web 端前端/后端同源 + (2) 云同步权威服务端",端点扩展为 4 项(/、/api、/mcp、/swagger+/admin),新增中间件顺序硬约束说明
- **验证**:`HostBootstrapAndAuthRedirectTests.cs` 6 个 ATDD 测试全部通过 + 原 `WebRootStaticFileTests.cs` 4 场景全部通过;`GetDiagnostics` 0 编译错误
## 六、最近一次重大重构(如有) ## 六、最近一次重大重构(如有)
- **术语统一与目录中文化**(2026-06): - **术语统一与目录中文化**(2026-06):
+16
View File
@@ -10,6 +10,22 @@
- v1.2.0:Linux 支持与增强功能 - v1.2.0:Linux 支持与增强功能
- v1.3.0(开发中):MCP 服务与语音控制 - v1.3.0(开发中):MCP 服务与语音控制
### v1.3.0 (2026-08-08,Host 双角色+路由+鉴权流修补)
- **修复:根路径 404 / 前端缺失引导**:`Hua.Todo.Host/Program.cs` 新增 `MinimalBootstrapPageMiddleware`(位于 Swagger 之后、UseDefaultFiles/UseStaticFiles 之前)。当前端 `wwwroot/index.html` 缺失时,访问 `/` 或 SPA 路由(如 `/settings`)不再返回 404,而是返回**中文最小引导页**(含期望路径、WebRoot、环境版本、三种修复方式:`dotnet build Host` / 手动 npm build / CI skip build;同时列出 `/swagger`、`/api/auth/bootstrap` 等仍可用的入口),解决了"根路径 404 看起来像后端挂了"的误判问题。
- **修复:没权限应该跳登录页**:`DynamicApiMiddleware` 新增 `WriteUnauthorizedResponse` / `WriteForbiddenResponse` / `IsBrowserHtmlRequest` 三个方法。判定规则:请求是 `GET`/`HEAD` 且 `Accept: text/html` 且 **非** `X-Requested-With: XMLHttpRequest` → 视为「用户在地址栏直访问接口页面」→ 返回 `302 Location: "/"`,回到 Host 前端 SPA,SPA 判断未登录 → 弹出登录/云同步设置 `CloudSyncSettingsDialog` 完成鉴权。**不破坏** axios/fetch:Accept 为 JSON 或非 GET/HEAD 时,继续返回原有的 401/403 JSON(`success=false + message`)。403 同样走 302 跳转。
- **架构文档对齐(Host 不区分模式 = 双角色统一部署)**:更新 `01-项目架构.md` 模式 B 章节:Host 部署时**同时承担 (1) Web 端前端/后端同源部署和 (2) 云同步权威服务端**两种职责,不区分"模式";端点列表扩展为 4 项(`/`、`/api/*`、`/mcp`、`/swagger + /admin/*`);明确 401/403 在浏览器页面时 302 跳 `/` 由 SPA 接管登录;新增中间件顺序硬约束链接。
- **验收测试补齐(ATDD)**:新增 `test/Hua.Todo.Host.Tests/CloudSync/HostBootstrapAndAuthRedirectTests.cs`,覆盖 6 场景:① 无 index.html 时 GET `/` + AcceptHtml → 200 + 引导页含「前端尚未构建」构建命令;② `/settings`(SPA 路由)同样 200 引导页;③ 未登录浏览器 GET `/api/security/policy`(PolicyRead 权限)+ AcceptHtml → 302 `/`;④ 未登录 AcceptJson 同一接口 → 401 JSON `success=false`;⑤ PUT `/api/security/policy`(非 GET)+ AcceptHtml → 仍 401 JSON 不跳转;⑥ 匿名 `POST /api/cloud-sync/probe` 不被 redirect 或 401 影响。6 个新测试 + 原 `WebRootStaticFileTests` 4 个场景 = 10 个全部通过。
### v1.3.0 (2026-06-17)
- **架构对齐(v1.3.0 工单 05)**:新增「Hua.Todo.HttpApi.AspNetCore」中间层到项目清单和依赖图(Core → Application → HttpApi.AspNetCore → {Host,Maui,Avalonia});抽离 `AddCloudSyncServer` 到 `CloudSyncServiceCollectionExtensions.cs`;清理 `01-项目架构.md` §5 过时的扩展点路径;`00-工单总览.md` 明确模式 B 中间件顺序硬约束,`MapCloudSyncProxySettings` 已废弃(接口改由 DynamicApi 自动暴露)。
- **Web 端根路径托管前端(v1.3.0 工单 06)**:模式 B 下 Host 同时承担「动态 API + 前端静态站点 + MCP」三大职责;`Program.cs` 新增 `UseDefaultFiles`/`UseStaticFiles`/`MapFallbackToFile("index.html")` 并对齐中间件顺序;`Host.csproj` 新增 `BuildHuaTodoWebWebRoot` Target(增量 npm build + 双份 wwwroot 拷贝 + Node 缺失 Warning);开发文档新增「模式 B 双开」与「BuildHuaTodoWebWebRoot 自动构建」指南;新增 `WebRootStaticFileTests.cs` 覆盖根路径 / SPA fallback / API 不覆盖 / Swagger 不覆盖 4 场景。
- **CloudSync Swagger + 路由修复**:CloudSync 接口改查 `IsMetadataEnabled`,移除 `IsEnabled=false`,Swagger 中 `/auth/* /tasks/* /security/* /admin/* /cloud-sync/*` 可见;MCP 工具改用命名空间过滤 `Hua.Todo.Application.CloudSync.*`,避免云同步接口暴露为 MCP。
- **CloudSync 端点 DynamicApi 化**:移除 16 个手动 `MapCloudSyncEndpoints()`,新增 5 个 `IDynamicApiService` 接口 + `DynamicApiRouteAttribute` + `RequirePermissionAttribute`,中间件支持自定义前缀与权限检查;错误响应统一 401/403。
- **测试项目重构**:`src/test/` → `test/`;Host.Tests 161 个测试全部通过。
- **Host 云同步端点补齐修复**:Program.cs 新增 `AddCloudSyncServer()` + `MapCloudSyncEndpoints()`(后已改为 DynamicApi) + `app.UseAuthentication()` 顺序;前端云同步默认账号 `admin`/`123456` + 默认地址 `http://localhost:5173`;`TaskList.vue` 新增登录/登出/同步按钮。
### v1.3.0 (2026-06-16) ### v1.3.0 (2026-06-16)
- **MCP 服务**:新增 MCP 协议端点(`/mcp`),DynamicMcpToolExtensions 自动扫描 IDynamicApiService 接口生成 MCP 工具;当前覆盖 ITaskService(9 个)+ IVoiceService(4 个)= 13 个 MCP 工具 - **MCP 服务**:新增 MCP 协议端点(`/mcp`),DynamicMcpToolExtensions 自动扫描 IDynamicApiService 接口生成 MCP 工具;当前覆盖 ITaskService(9 个)+ IVoiceService(4 个)= 13 个 MCP 工具
+58 -8
View File
@@ -27,19 +27,60 @@ npm install
### 1.3 启动开发环境 ### 1.3 启动开发环境
**方式一:Windows 三件套(推荐)** #### 方式一:模式 B「Web 端双开」(推荐,调试 Host API + 前端)
> 模式 B = Hua.Todo.Host 同时承担「动态 API + 前端静态站点 + MCP」三大职责,
> 访问 `http://localhost:5173/` 即可看到完整 Todo 前端,HTTP 同源无需再配 Vite proxy。
开发调试时分两个终端(修改前端能 HMR 热更新,不会每次都触发 npm build):
```powershell ```powershell
# 终端 1:启动 Host(API 服务端) # 终端 1:启动 Host(监听 :5173 HTTP + :7055 HTTPS,未登录时 HTTP 即可)
.\start-host.ps1 # ⚠️ 若尚未构建过前端,Host 直接启动后 GET / 返回 404。首次需要先 build 一次:
# cd src/Hua.Todo.Web && npm ci && npm run build
# 终端 2:启动前端开发服务器 # 后续 build 会被 MSBuild Target 自动触发,见 1.4 节。
.\start-dev.ps1 cd src/Hua.Todo.Host
dotnet run # 或 Visual Studio F5 调试 Hua.Todo.Host
``` ```
**方式二:MAUI 嵌入模式** ```powershell
# 终端 2:Vite 开发服务器(:5174,负责前端 HMR;通过 .env.development 把 API 打回 :5173 Host)
cd src/Hua.Todo.Web
npm run dev
# 浏览器访问 http://localhost:5174 → 代码保存后浏览器立即刷新
```
在 Visual Studio 中直接调试运行 `Hua.Todo.Maui` 项目,MAUI 会自动启动内嵌 WebServer 并加载前端。 > 关闭 Vite 后直接访问 `http://localhost:5173/` 也能看到上次构建的前端静态页(非 HMR)。
#### 方式二:模式 A「MAUI 嵌入模式」
在 Visual Studio 中直接调试运行 `Hua.Todo.Maui` 项目,MAUI 会自动启动内嵌 WebServer(`localhost:5057`)并加载前端。
云同步链路:前端同源调用 `:5057` → CloudSyncProxy → 用户配置的 Web 端地址(默认 `http://localhost:5173`)。
#### 方式三:纯 Host 发布模式(快速验证部署产物)
```powershell
# 单次构建(前端 dist + Host 二进制 + wwwroot 复制)
dotnet build src/Hua.Todo.Host -c Release
dotnet run -c Release --project src/Hua.Todo.Host
# 浏览器访问 http://localhost:5173/ → 与部署后的体验一致
```
### 1.4 BuildHuaTodoWebWebRoot 自动构建说明
`src/Hua.Todo.Host/Hua.Todo.Host.csproj` 内置 `BuildHuaTodoWebWebRoot` MSBuild Target(触发时机:`BeforeBuild`),每次 `dotnet build/publish Hua.Todo.Host` 时执行:
| 行为 | 说明 |
|---|---|
| 增量构建 | 仅当 `src/Hua.Todo.Web/package.json` 或 `src/**/*.{ts,vue,css,html}` 变化时才 npm build,否则跳过 |
| Node 缺失保护 | 未安装 Node/npm 时仅输出 Warning,不阻塞 Host C# API 层编译(CI 可在外层单独 build 前端) |
| 跳过开关 | `dotnet build /p:SkipBuildHuaTodoWebWebRoot=true` 禁用整个 Target |
| 双份拷贝 | 产物同时写入 `src/Hua.Todo.Host/wwwroot/`(本地 `dotnet run`)和 `bin/Release/net10.0/wwwroot/`(`dotnet publish` 打包) |
常见提示排查:
- **`[BuildHuaTodoWebWebRoot] 未检测到 Node/npm`**:安装 Node.js 18+ 并重启终端;或 CI 先 `cd src/Hua.Todo.Web && npm ci && npm run build`。
- **`[BuildHuaTodoWebWebRoot] npm run build 退出码 != 0`**:去 `src/Hua.Todo.Web` 手动 `npm run build` 看 TypeScript/Vite 报错,修复后再 build Host。
## 2. 版本构建 (Release Build) ## 2. 版本构建 (Release Build)
@@ -68,6 +109,15 @@ npm install
## 3. 服务端部署 ## 3. 服务端部署
### 3.0 构建流程(新增:前端 dist 自动进 wwwroot)
v1.3.0 起 `dotnet publish src/Hua.Todo.Host` 会自动执行:
1. `npm install`(仅 node_modules 不存在时)
2. `npm run build` → `src/Hua.Todo.Web/dist`
3. `dist/**/*` → `publish_output/wwwroot/`(保留目录结构)
因此部署前**不再需要**单独 build 前端,直接一条命令即可。CI 若在容器内无 Node,设置 `-p:SkipBuildHuaTodoWebWebRoot=true` 并在外层先 `npm ci && npm run build`。
### 3.1 Docker 部署(推荐) ### 3.1 Docker 部署(推荐)
```bash ```bash
@@ -51,3 +51,5 @@
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物 - Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
- Search:可在主界面按 Todo 待办项标题实时过滤(含层级 Todo 的展示策略清晰) - Search:可在主界面按 Todo 待办项标题实时过滤(含层级 Todo 的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户的 Todo 数据;服务端可下发"是否允许落盘";客户端在禁止落盘时不产生本地持久化 - 云同步(基础可用):可配置服务端地址;登录后可拉取该用户的 Todo 数据;服务端可下发"是否允许落盘";客户端在禁止落盘时不产生本地持久化
@@ -40,6 +40,16 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
当前版本暂无串行工单依赖。 当前版本暂无串行工单依赖。
### 2.4 补充工单(架构对齐,2026-08-08 新增)
> 按 [04-研发工单全流程规范.md](../../../.trae/rules/全局/04-研发工单全流程规范.md) §2.3「新增工单约束」:已有条目不可改写,本章节为本次**新增**。触发原因:本次 307 修复后重写 [01-项目架构.md](../../../.trae/rules/项目/01-项目架构.md) §3/§5,对照代码发现 2 处架构缺口(文档遗漏 HttpApi.AspNetCore 层、Web 端根路径无前端托管)。
| 工单编号 | 标题 | 负责人 | 状态 | 并行性 |
|---|---|---|---|---|
| 05 | 架构文档与代码实际路径对齐 | - | 待开始 | 与 03/06 并行 |
| 06 | Web 端根路径托管前端 | - | 待开始 | 与 03/05 并行(建议 05 缺口 D 先落,再推 06 Program.cs 集成) |
| 07 - 数据库初始化默认管理员 | 待实现 | 待验证 | Host 服务端 DB Migrate 后自动创建默认管理员账号;仅 Host 模式;配置驱动 + 幂等 + 强制首登改密;作为 06.1 Bootstrap 的自动前置项。详见 [10-数据库初始化默认管理员.md](10-数据库初始化默认管理员.md) |
--- ---
## 三、各子工单摘要 ## 三、各子工单摘要
@@ -123,6 +133,33 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
- 本地附件可通过系统关联程序打开 - 本地附件可通过系统关联程序打开
- 附件数量(20个)和大小(50MB)限制生效 - 附件数量(20个)和大小(50MB)限制生效
### 3.5 工单 05 - 架构文档与代码实际路径对齐
**目标**:修正 01-项目架构.md §1/§2/§5 共 5 处与实际代码结构不一致的描述,使规则文档成为代码的单一事实来源。
**核心需求**:
- **文档侧**:§1 项目清单补 `Hua.Todo.HttpApi.AspNetCore` 条目;§2 ASCII 依赖图加该中间层;§5 路径修正 Mcp 描述
- **代码侧**:`CloudSyncServerService.cs` 抽离 `AddCloudSyncServer()` 到新文件 `CloudSyncServiceCollectionExtensions.cs`;新建 `CloudSyncEndpointExtensions.cs` 还原 `MapCloudSyncProxySettings()`
- **回归**:Host 编译 + 161 个既有测试全部通过
**不包含**:业务逻辑变更、公共 API 签名修改、v1.2.0 工单 10(DataSeeder)
**验收标准**:详细 5 条 Given-When-Then 见 [05-架构文档与代码实际路径对齐.md](./05-架构文档与代码实际路径对齐.md) §五。
### 3.6 工单 06 - Web 端根路径托管前端
**目标**:补齐模式 B(Hua.Todo.Host Web 端)的前端静态页托管链路,使 `dotnet publish Hua.Todo.Host` 产出一个「页面 + API + Admin 占位」完整可部署的服务。
**核心需求**:
- Host `Program.cs`:`UseDefaultFiles` + `UseStaticFiles` + `MapFallbackToFile("index.html")`(支持 SPA 路由)
- Host `csproj`:新增 `BuildHuaTodoWebWebRoot` Target(`npm install`/`npm run build`/`copy dist → wwwroot`,Inputs/Outputs 支持增量构建)
- 文档:`docs/manual/08-构建与开发指南.md` 追加模式 B 开发双开步骤 + 生产发布步骤
- 测试:`WebRootStaticFileTests.cs` 验证根路径 `/` 与 SPA fallback 不覆盖 `/api/*`
**不包含**:Admin 管理后台前端实现(v1.2.0 工单 06.1)、嵌入式模式前端托管(`EmbeddedWebServerService` 各自管理)
**验收标准**:详细 6 条 Given-When-Then 见 [06-Web端根路径托管前端.md](./06-Web端根路径托管前端.md) §四。
--- ---
## 四、待验证表 ## 四、待验证表
@@ -149,6 +186,14 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 04 | 附件数量/大小限制 | 待验证 | 20 个 / 50MB | | 04 | 附件数量/大小限制 | 待验证 | 20 个 / 50MB |
| 04 | 待办项删除时附件级联清理 | 待验证 | - | | 04 | 待办项删除时附件级联清理 | 待验证 | - |
| 04 | 跨平台编辑安全(移动端不覆盖描述/附件) | 待验证 | 桌面设值 → 移动端改标题 → 桌面验证不丢失 | | 04 | 跨平台编辑安全(移动端不覆盖描述/附件) | 待验证 | 桌面设值 → 移动端改标题 → 桌面验证不丢失 |
| 05 | 规则文档 §1/§2 补 HttpApi.AspNetCore 条目与依赖图 | 待验证 | Glob 到项目 + 规则描述一致 |
| 05 | §5 扩展点 AddCloudSyncServer / MapCloudSyncProxySettings / Mcp 3 个路径修复 | 待验证 | 规则路径 = Grep 实际路径;Host 编译 0 错误 |
| 05 | 既有 AddCloudSyncServer 回归测试 | 待验证 | `CloudSyncEndpointRegistrationTests` 5 个用例全部通过 |
| 06 | Host `/` 返回 Hua.Todo.Web index.html(200 + div#app) | 待验证 | `WebRootStaticFileTests.Root_Get_ReturnsIndexHtml` 通过 |
| 06 | SPA 路由 `/settings` fallback 到 index.html | 待验证 | 不 404;不覆盖 `/api/*` 动态 API |
| 06 | `dotnet publish Hua.Todo.Host` 产出包含前端静态资源 | 待验证 | publish/wwwroot/index.html 存在 + 资源齐全 |
| 06 | 增量构建前端未变时跳过 npm build | 待验证 | MSBuild Inputs/Outputs 检测 Skipping target 提示 |
| 06 | 模式 B 开发双开文档可执行性 | 待验证 | 从零 clone → 文档 step by step 跑通页面 + API |
--- ---
@@ -172,6 +217,9 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 附件存储策略 | 附件存储在应用数据目录 `Attachments/` 子目录;外部链接不复制文件仅存 URL;单文件 50MB / 每待办项 20 个上限 | | 附件存储策略 | 附件存储在应用数据目录 `Attachments/` 子目录;外部链接不复制文件仅存 URL;单文件 50MB / 每待办项 20 个上限 |
| 外部程序启动 | 通过 `IPlatformAttachmentOpener` 接口实现平台差异:Windows 用 `Process.Start`,Linux 用 `xdg-open` | | 外部程序启动 | 通过 `IPlatformAttachmentOpener` 接口实现平台差异:Windows 用 `Process.Start`,Linux 用 `xdg-open` |
| 工单 04 与 03 共享文件 | `TaskEntity.cs`、`TodoDbContext.cs`、`task.ts`、`TaskEditDialog.vue` 为共享文件,工单 03 先写入,04 后续追加 | | 工单 04 与 03 共享文件 | `TaskEntity.cs`、`TodoDbContext.cs`、`task.ts`、`TaskEditDialog.vue` 为共享文件,工单 03 先写入,04 后续追加 |
| **架构文档 ↔ 代码一致性强制策略** | 后续凡改 01-项目架构.md 任何一节,改后必须立即对 §1/§2/§5 所有提到的文件路径做一次 Glob/Grep 校验;不一致 → 先开对齐工单,不直接碰业务代码(本次 05 工单即为策略首次落地案例) |
| **Host 中间件顺序固定模板** | Swagger → UseDefaultFiles/UseStaticFiles → CORS → Authentication → Authorization → UseDynamicApi → MapMcpServer → MapFallbackToFile → Run(顺序错误直接判作编译/验收失败) |
| **MSBuild 前端构建 Target 命名与增量** | Host 前端构建 Target 固定名 `BuildHuaTodoWebWebRoot`;必须显式写 `Inputs="$(HuaTodoWebSrc);$(HuaTodoWebPkgJson)" Outputs="$(WebRoot)/index.html"` 支持增量;Node/npm 不存在时只 Warning 不阻塞 API 层 build |
--- ---
@@ -185,6 +233,9 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| LLM API(AI 拆分) | 待确认 | Host 端调用 | | LLM API(AI 拆分) | 待确认 | Host 端调用 |
| 浏览器 MediaRecorder API | 已就绪 | 前端录音 | | 浏览器 MediaRecorder API | 已就绪 | 前端录音 |
| 工单 02 LlmClientService | 待实现 | 会议拆分复用 | | 工单 02 LlmClientService | 待实现 | 会议拆分复用 |
| 工单 05 前置(缺口 D) | 待完成 | 建议先还原 `CloudSyncEndpointExtensions.cs`(MapCloudSyncProxySettings),再推 06 Program.cs 集成 |
| Node.js + npm(构建机器) | 外部依赖 | 构建 Hua.Todo.Web 必备;缺失时 Host csproj Target 仅 Warning |
| 01-项目架构.md §3 模式 B 端点划分表 | 已固化 | 本批次工单 05/06 的对齐基准 |
--- ---
@@ -199,9 +250,25 @@ Hua.Todo v1.3.0 版本聚焦于三个核心能力的升级:
| 会议录音文件过大 | 上传超时/存储压力 | 前端限制最长 2 小时;压缩音频格式 | | 会议录音文件过大 | 上传超时/存储压力 | 前端限制最长 2 小时;压缩音频格式 |
| 浏览器 MediaRecorder 兼容性 | 部分平台录音不可用 | 降级提示使用文字输入 | | 浏览器 MediaRecorder 兼容性 | 部分平台录音不可用 | 降级提示使用文字输入 |
| STT 转写准确率不足 | 会议纪要质量差 | 转写后支持用户编辑修正 | | STT 转写准确率不足 | 会议纪要质量差 | 转写后支持用户编辑修正 |
| **工单 05 风险:架构文档与代码路径反复漂移** | 后续智能体又按旧路径改错文件 | 05 验收完成后,01-项目架构.md §2 ASCII 图顶部加"最后一次代码对齐时间戳";后续改架构章节必须更新时间戳 + 走 Glob 校验 |
| **工单 05 风险:抽离 AddCloudSyncServer 时丢 using/命名空间** | Host 编译失败 | 改动范围严格控制在 CloudSync 两个文件;Program.cs 仅补 using;单步 build 验证后再提交 |
| **工单 06 风险:MapFallbackToFile 覆盖 /api 或 /swagger** | API/Swagger 全 404 | 中间件顺序严格按 §5 固定模板;集成测试同时验证 `/`、`/settings`(fallback)、`/api/cloud-sync/probe`(DynamicApi)、`/swagger`(Swagger UI)四个端点 |
| **工单 06 风险:CI 环境 npm 网络差/无 Node** | publish 超时或失败 | MSBuild Target 加 Node 缺失检测 → Warning + 跳过;生产部署可在外层 CI 先 build 前端再 publish Host |
| **工单 06 风险:增量构建 Inputs/Outputs 粗粒度** | 前端代码改了不重 build 或每次都重 build | Inputs 精确到 `**/*.{ts,vue,css,html}` + `package.json`;Outputs 精确到 `wwwroot/index.html`,首次过 CI 后人工改一行 .vue 验证是否触发 rebuild |
--- ---
## 补充工单(追加于 2026-08-08)
### 工单 10 - 数据库初始化默认管理员
- **目标**:Host 服务端 DB Migrate 完成后自动创建默认管理员账号,免去手动调用 Bootstrap API
- **范围**:仅 `Hua.Todo.Host`;嵌入式模式(MAUI/Avalonia)不涉及
- **关联**:作为 06.1 服务端安全设计 Bootstrap 的自动前置项;与现有 `BootstrapAdmin` API 共存
- **详见**:[10-数据库初始化默认管理员.md](10-数据库初始化默认管理员.md)
---
**创建日期**:2026-06-15 **创建日期**:2026-06-15
**修订日期**:2026-06-16 **修订日期**:2026-08-08
**版本**:v1.3.0 **版本**:v1.3.0
**修订说明(2026-08-08)**:新增架构对齐补充工单 05/06(01-项目架构.md §3 模式 B 端点划分表与代码实际不一致触发);所有新增条目均以"追加末尾"形式写入,未改动 2026-06-16 版本已有表格行和章节结构(符合 04-研发工单全流程规范 §2.1 已有文件不可触碰)。
@@ -0,0 +1,105 @@
# 研发工单 v1.3.0 - 05:架构文档与代码实际路径对齐
> 术语澄清:本文件中"研发工单"指智能体/开发者执行的**编码工作项**,与业务侧的 Todo 待办项无关。详见 [.trae/rules/全局/04-研发工单全流程规范.md](../../../.trae/rules/全局/04-研发工单全流程规范.md)。
---
## 一、背景与动机
在修正「云同步探测 307 → HTTPS 重定向规则」时,重写了 `.trae/rules/项目/01-项目架构.md` §3「两种运行模式」与 §5「关键扩展点」以固化正确链路。但对照代码后发现:**规则文档中的两处描述已与当前代码结构不一致**,会导致后续智能体/开发者按规则路径搜索文件时直接落空,进而引发与本次 307 同类的"链路理解错位 → 改错文件"问题。
本工单是纯**工程化对齐工单**(无用户可见的业务功能变更),目标是让 01-项目架构.md 成为"代码的单一事实来源":要么文档改到与代码一致,要么代码重构到与文档一致,**不允许中间的灰色状态**。
## 二、缺口清单(代码 vs 01-项目架构.md 规则)
| # | 缺口 | 规则写的内容 | 代码实际情况 | 本工单处理方式 |
|---|---|---|---|---|
| A | **§1 项目清单漏列 HttpApi.AspNetCore 层** | Core / Application / Host / Maui / Avalonia / Web 共 6 行 | 存在 `src/Hua.Todo.HttpApi.AspNetCore/`(CloudSync/DynamicApi/Mcp 三层 ASP.NET Core 专属实现),Host 直接引用它 | **规则加表项**:补齐项目条目,注明"承载 ASP.NET Core 专属的 HTTP 层代码(Application 保持 netstandard 兼容)" |
| B | **§2 依赖方向图缺失 HttpApi.AspNetCore 中间层** | `Application ← { Host, Maui, Avalonia }` 直接三层 | 实际是 `Application ← HttpApi.AspNetCore ← Host`,MAUI/Avalonia 只按需引用 HttpApi.AspNetCore 的部分子模块 | **规则改 ASCII 图**:新增中间层 + 注释跨层引用的边界 |
| C | **§5 扩展点路径:AddCloudSyncServer 所在文件名错误** | `Hua.Todo.HttpApi.AspNetCore/CloudSync/CloudSyncServiceCollectionExtensions.cs` | 定义在 `CloudSync/Services/CloudSyncServerService.cs` 内(非 *Extensions 命名,与 Mcp / DynamicApi 的命名习惯不一致) | **代码重命名/抽离**:新建 `CloudSyncServiceCollectionExtensions.cs`,把 `AddCloudSyncServer()` 移入;原 `CloudSyncServerService.cs` 仅保留服务实现类 |
| D | **§5 扩展点路径:MapCloudSyncProxySettings 所在文件缺失** | `Hua.Todo.HttpApi.AspNetCore/CloudSync/CloudSyncEndpointExtensions.cs`(仅保留 `MapCloudSyncProxySettings`) | Grep 显示该方法**仅被 Host Program.cs 调用,定义文件不存在**(即时状态记忆 2026-06-17 条目说"仅保留 MapCloudSyncProxySettings",但文件未落地) | **代码补齐**:新建 `CloudSyncEndpointExtensions.cs`,写入 `MapCloudSyncProxySettings` 扩展方法(将目前分散的或在旧文件被删除的内容还原),并更新引用命名空间 |
| E | **§5 扩展点:McpServerMiddleware 实际不存在** | `DynamicMcpToolExtensions.cs + McpServerMiddleware` | 只有 `McpEndpointExtensions.cs`(`MapMcpServer`)+ `McpServiceCollectionExtensions.cs`(`AddMcpServerServices`),无 Middleware 类型 | **规则改路径**:改为 `McpEndpointExtensions.cs` + `McpServiceCollectionExtensions.cs`;不强求新增 Middleware 类型(EndpointRouting 扩展符合 ASP.NET Core 主流模式),只改描述 |
## 三、目标 / 范围
### 3.1 做什么
- 对齐 01-项目架构.md §1/§2/§5 三章共 5 处缺口(A–E,见上表)
- 代码改动:C(文件抽离+重命名)、D(新建+还原)
- 文档改动:A、B、E(仅改规则文档,不改代码)
### 3.2 不做什么
- 不改业务逻辑(不触碰 CloudSyncServerService 内的方法体、不改动 DynamicApiMiddleware 行为)
- 不改公共 API 契约(AddCloudSyncServer / MapCloudSyncProxySettings 的签名保持不变,仅迁移到新文件)
- 不做 v1.2.0 工单 10 范围内的工作(DataSeeder)
## 四、前置条件 / 依赖
- 无;可与 v1.3.0 工单 06、03 并行执行
- 共享文件:`.trae/rules/项目/01-项目架构.md`(只读,只做局部追加替换;不重排原有章节结构)
- 共享文件:`Hua.Todo.HttpApi.AspNetCore.csproj`(新增 2 个源文件,C# 编译级冲突风险极低)
## 五、验收标准(Given-When-Then)
| # | 场景 | GIVEN | WHEN | THEN |
|---|---|---|---|---|
| 1 | 缺口 A/B(规则补层) | - | 打开规则文档 §1/§2 | §1 出现 `Hua.Todo.HttpApi.AspNetCore` 表项,描述包含"ASP.NET Core 专属 HTTP 层";§2 ASCII 图新增中间层,箭头方向正确(单向 Application → HttpApi.AspNetCore → Host) |
| 2 | 缺口 C(DI 扩展抽离新文件) | `CloudSyncServerService.cs` 当前包含 `AddCloudSyncServer(this IServiceCollection)` | 抽离为 `CloudSyncServiceCollectionExtensions.cs` | 新文件存在且命名空间为 `Hua.Todo.HttpApi.AspNetCore.CloudSync`;`Program.cs` 编译通过无需改 using;旧文件仅保留服务实现(非 static / 非扩展方法)|
| 3 | 缺口 D(MapCloudSyncProxySettings 新建文件) | `MapCloudSyncProxySettings(this IEndpointRouteBuilder)` 在 Program.cs 被调用但无定义文件 | 创建 `CloudSyncEndpointExtensions.cs` 并补全方法体 | 新文件存在;`Program.cs` 加对应 using 后编译通过;该端点在 Host 启动后响应 `/api/cloud-sync/proxy-settings`(与既有集成测试一致) |
| 4 | 缺口 E(规则改 Middleware 描述) | §5 写 "McpServerMiddleware" | 将 §5 对应行改为 "McpEndpointExtensions.cs(MapMcpServer 端点) + McpServiceCollectionExtensions.cs(DI 注册)" | 规则路径描述与实际文件一一对应,Grep 两个路径都能找到 |
| 5 | 回归(全部编译通过) | - | `dotnet build src/Hua.Todo.Host/Hua.Todo.Host.csproj` + `dotnet test test/Hua.Todo.Host.Tests/` 全量 | 0 编译错误;既有测试(含 AddCloudSyncServer DI 注册测试)全部通过,不允许新增失败用例 |
## 六、并行冲突规避
| Touch List | 共享文件策略 | 编译绿线策略 |
|---|---|---|
| `.trae/rules/项目/01-项目架构.md` | 追加/局部替换,**不改 §3 模式 A/B 的正文段落和 §6 版本统一/§7 测试架构**;本工单只动 §1 表、§2 ASCII 图 + 说明、§5 表 | 纯 Markdown,无编译;提交前 diff 确认未改动他人段落 |
| `Hua.Todo.HttpApi.AspNetCore/CloudSync/CloudSyncServerService.cs` | 只移除 `AddCloudSyncServer` 静态方法块,**不改动类的其他成员**;新文件独立写入,无双向合并 | `dotnet build Hua.Todo.HttpApi.AspNetCore.csproj` 必须 0 错误 |
| `Hua.Todo.HttpApi.AspNetCore/CloudSync/CloudSyncServiceCollectionExtensions.cs` | 新建;其他并行工单不改同名文件 | 同上 |
| `Hua.Todo.HttpApi.AspNetCore/CloudSync/CloudSyncEndpointExtensions.cs` | 新建;其他并行工单不改同名文件 | 同上 |
| `Hua.Todo.Host/Program.cs` | 可能需要追加 `using Hua.Todo.HttpApi.AspNetCore.CloudSync;`(如果原 using 不完整);不碰其他语句 | `dotnet build Hua.Todo.Host.csproj` 0 错误 |
## 七、风险与回滚
| 风险 | 影响 | 回滚策略 |
|---|---|---|
| C/D 移动方法体时遗漏了 using 或扩展方法的 `this` 参数 | Host 编译失败 | git 单文件 revert;不要手改多个地方 |
| §2 依赖方向图改动后,后续智能体仍按旧图改代码 | 跨工单改错 | 规则文档 §2 顶部加一句"本图为 2026-08-08 v1.3.0 工单 05 更新版"作为时间戳标记 |
| D 还原的 MapCloudSyncProxySettings 方法体与"即时状态记忆 2026-06-17"实际实现不一致 | 端点行为变化 | 先从 git 历史(CloudSyncEndpointExtensions.cs 被删前的版本)原样恢复,不做任何逻辑修改 |
## 八、与其他工单的语音/UI 入口衔接
> 按 [05-多入口功能同步规范.md](../../../.trae/rules/项目/05-多入口功能同步规范.md) 第 3.2 条:纯基础设施调整无需检查双入口。
| 入口 | 已规划 | 方案 |
|---|---|---|
| UI | ❌(无需) | 纯工程化,用户无感知 |
| 语音 | ❌(无需) | 不涉及业务操作 |
---
**创建日期**:2026-08-08
**关联规则**:[01-项目架构.md](../../../.trae/rules/项目/01-项目架构.md)
**关联工单**:v1.2.0 工单 08 / 工单 10;v1.3.0 工单 01 / 06
---
## 九、执行期设计修正(2026-08-08,不违反 §2.1:本段落为本次执行前新增,未改动 §二 缺口表原有行)
### 9.1 缺口 D 重判:`MapCloudSyncProxySettings` 实际上不应作为手动 Map 存在
**执行前代码核对结果**:`ICloudSyncProxySettingsService` 已继承 `IDynamicApiService`(见 `src/Hua.Todo.Application/Services/CloudSync/Services/ICloudSyncProxySettingsService.cs`),因此代理设置端点(GET/POST `/api/cloudSyncProxySettings`)**由 DynamicApiMiddleware 自动暴露**。即时状态记忆 2026-06-17 条目记录的「CloudSyncEndpointExtensions 仅保留 `MapCloudSyncProxySettings`」属于"CloudSync 端点 DynamicApi 化"过程中的中间状态快照:移除 16 个手动 Map 时,旧 MapCloudSyncProxySettings 本应连同删除却留下了文档引用,造成规则文档 §5 出现了「云同步代理设置端点 → CloudSyncEndpointExtensions.cs」这个错误路径。
**修正后缺口 D**:从「代码侧新建 CloudSyncEndpointExtensions.cs 还原方法」**改为「规则侧清理过时引用」**:
- 01-项目架构.md §5:把「云同步代理设置端点 → CloudSyncEndpointExtensions.cs」改为 `ICloudSyncProxySettingsService`(DynamicApi 自动暴露,仅在嵌入式 AddCloudSyncProxy 时注册)
- v1.3.0 00-工单总览 §5 关键决策「Host 中间件顺序固定模板」:末尾移除 `→ MapCloudSyncProxySettings`(Host 上代理设置端点本来就不注册)
- 代码侧:不新建 CloudSyncEndpointExtensions.cs,也不在 Program.cs 追加 `MapCloudSyncProxySettings()` 调用
**验收影响**:
- §五 验收 #3 对应取消;新增验收 #3b:01-项目架构.md §5 中不再出现 `CloudSyncEndpointExtensions.cs` 和 `MapCloudSyncProxySettings` 两个字符串;`ICloudSyncProxySettingsService` 行出现且描述正确
- §五 验收 #5 回归编译仍然保留(仍然是 0 错误 + 既有测试全过)
### 9.2 其余缺口 A/B/C/E 不变
A(§1 补条目)、B(§2 ASCII 补中间层)、C(抽离 AddCloudSyncServer 到 *Extensions 文件)、E(Mcp 描述改 EndpointExtensions + ServiceCollectionExtensions)与代码实际核对一致,按原计划执行。
@@ -0,0 +1,89 @@
# 研发工单 v1.3.0 - 06:Web 端根路径托管前端
> 术语澄清:本文件中"研发工单"指智能体/开发者执行的**编码工作项**,与业务侧的 Todo 待办项无关。详见 [.trae/rules/全局/04-研发工单全流程规范.md](../../../.trae/rules/全局/04-研发工单全流程规范.md)。
---
## 一、背景与动机
根据 [01-项目架构.md](../../../.trae/rules/项目/01-项目架构.md) §3「模式 B:Web 端」端点划分表的定义:
| URL 前缀 | 内容 | 来源 |
|---|---|---|
| `/`(根路径) | **Hua.Todo.Web 用户前端**(登录后 Todo 列表) | `src/Hua.Todo.Web` 的 `dist/` 构建产物,复制到 `Hua.Todo.Host/wwwroot/` |
| `/api/*` | 动态 API | DynamicApi |
| `/admin/*` | 管理后台前端 | `Hua.Todo.Host/wwwroot/admin/`(占位) |
**当前代码不符合**:
- Host `Program.cs` 没有 `UseStaticFiles()` / `UseDefaultFiles()`;访问 `http://localhost:5173/` 返回 **404**
- `Hua.Todo.Host.csproj` 没有任何构建 Target 把 `Hua.Todo.Web` 的 `dist/*` 产物拷到 `Hua.Todo.Host/wwwroot/`
- 开发联调文档缺失:开发时 Vite(:5174)+ Host(:5173)双开怎么跑、端口怎么代理、生产部署怎么构建单文件,都没有书面说明
结果:**用户按规则部署"Web 端(Hua.Todo.Host)",会拿到一个只有 API 没有前端页面的半成品服务**。本工单补全模式 B 的生产/开发闭环。
## 二、目标 / 范围
### 2.1 做什么
1. **Host 侧 ASP.NET Core 中间件补齐**:在 `Program.cs` DynamicApi 之前插入 `UseDefaultFiles()` + `UseStaticFiles()`,并保证 SPA 路由(前端内部路由,如 `/settings`)fallback 到 `index.html`(用 `MapFallbackToFile("index.html")`)
2. **Host csproj 构建 Target**:新增 `npm install`(若 `node_modules` 不存在)+ `npm run build`(Hua.Todo.Web)+ 复制 `dist/**` → `$(OutputPath)/wwwroot/` 的 MSBuild Target,只在构建 Host 时触发(确保 `dotnet publish Hua.Todo.Host` 产出含前端的完整包)
3. **开发环境联调说明**:在 `docs/manual/08-构建与开发指南.md`(或现有的开发指南对应章节)追加 "模式 B(Web 端)开发双开步骤" 与 "生产发布步骤" 小节
4. **验收测试**:新增 1 个 Host 集成测试(`WebRootStaticFileTests`),验证 HTTP GET `/` 返回 200 且 body 含 `<title>`/`<div id="app">` 特征
### 2.2 不做什么
- **不实现 `/admin/*` 管理后台前端**(只有 index.html 占位,v1.2.0 工单 06.1 规划)
- **不改嵌入式模式 MAUI/Avalonia 的前端托管逻辑**(那边是 `EmbeddedWebServerService` 内自己 UseWebRoot + 复制 wwwroot,与 Host 部署模式解耦)
- **不改 Hua.Todo.Web 构建逻辑本身**(只调用现成 `npm run build`;不修改 vite.config.ts build.outDir)
## 三、前置条件 / 依赖
- v1.3.0 工单 05 缺口 D:`CloudSyncEndpointExtensions.cs` 的还原落地(因为本工单需要在 Program.cs 末尾追加 `MapFallbackToFile`,和 `MapCloudSyncProxySettings()` 同时存在于 `Program.cs` 尾部,改动顺序不敏感但建议先落 05 的 D)
- Node.js + npm 在构建机器可用(作为前置条件,Host csproj Target 不负责安装 Node)
- 共享文件:`Hua.Todo.Host/Program.cs`、`Hua.Todo.Host/Hua.Todo.Host.csproj`、`docs/manual/08-构建与开发指南.md`
- 可与 v1.3.0 工单 03、05 并行
## 四、验收标准(Given-When-Then)
| # | 场景 | GIVEN | WHEN | THEN |
|---|---|---|---|---|
| 1 | 根路径返回前端 HTML(模式 B) | Host 已 `dotnet build`,且 `wwwroot/index.html` 有内容 | HTTP GET `http://localhost:5173/` | 返回 200;`Content-Type: text/html`;body 含 `<div id="app">`(来自 Hua.Todo.Web 模板/index.html) |
| 2 | SPA 路由 fallback | 同 1 | HTTP GET `http://localhost:5173/settings`(前端内部路由) | 返回 200;body 是 `index.html` 全文,**不是** 404 页 |
| 3 | API 路由不被 fallback 覆盖 | 同 1 | HTTP POST `/api/cloud-sync/probe` | DynamicApi 正常处理(200 / 503 都行,但不是 index.html) |
| 4 | MSBuild Target:`dotnet publish Hua.Todo.Host` | 干净克隆(删 node_modules 和 dist),Node 已装 | 执行 `dotnet publish src/Hua.Todo.Host -c Release -o ./publish-host` | `publish-host/wwwroot/` 下有 `index.html`、`assets/` 等 Hua.Todo.Web build 产物;且 publish-host 启动后 `GET /` 满足验收 1 |
| 5 | MSBuild Target 增量构建(不重复 npm run build) | Hua.Todo.Web src 文件未变动,已执行过一次 build | 再次执行 `dotnet build src/Hua.Todo.Host` | npm build 阶段跳过后直接 copy(Inputs/Outputs 正确);日志出现 "Skipping target BuildHuaTodoWeb"(或等价跳过提示) |
| 6 | 开发联调(文档可执行) | 新环境 clone 后 Node 已装、.NET 10 SDK 已装 | 按 docs/manual 新增章节的步骤执行(两条命令:1. Vite dev;2. Host run) | Vite :5174 页面打开、API 请求能到 Host :5173;云同步探测能返回 `isReachable` JSON(与 307 修复后的链路一致) |
## 五、并行冲突规避
| Touch List | 共享文件策略 | 编译绿线策略 |
|---|---|---|
| `Hua.Todo.Host/Program.cs` | 只在 `app.UseDynamicApi();` **之前**插入 `UseDefaultFiles/UseStaticFiles`;在 `app.Run()` **之前**追加 `MapFallbackToFile("index.html")`(若已存在 `/api/cloud-sync/proxy-settings` endpoint 则不改动) | `dotnet build Hua.Todo.Host.csproj` 0 错误 |
| `Hua.Todo.Host/Hua.Todo.Host.csproj` | 追加 `<Target Name="BuildHuaTodoWebWebRoot" BeforeTargets="BeforeBuild" ...>` 节点;**不修改任何现有 PropertyGroup/ItemGroup/Target** | 同编译 + `dotnet publish` 产出验证 |
| `docs/manual/08-构建与开发指南.md` | 末尾追加两个新小节标题,不改原有章节顺序 | 纯 Markdown |
| `test/Hua.Todo.Host.Tests/...` | 新建 `WebRootStaticFileTests.cs`(独立文件),不修改既有测试类 | `dotnet test Hua.Todo.Host.Tests` 新增 + 既有全部通过 |
## 六、风险与回滚
| 风险 | 影响 | 应对策略 |
|---|---|---|
| `npm install` 时长慢(首次)+ 网络差导致构建失败 | CI publish 超时 | MSBuild Target 加 `ContinueOnError="WarnAndContinue"` + 清晰错误日志:"Hua.Todo.Web build skipped,deploy wwwroot 是空的——请手动构建 Hua.Todo.Web 后重试" |
| `MapFallbackToFile` 顺序放错,覆盖掉 `/api/*` 或 `/swagger/*` | API / Swagger 404 | 在 Program.cs 写明确顺序:`UseSwagger → UseStaticFiles → UseAuthentication/Authorization → UseDynamicApi → MapMcpServer → MapFallbackToFile`;并在集成测试里同时验证 fallback 和 API 都正常 |
| 增量构建 Inputs/Outputs 写粗了导致漏 build(或反复 build) | 前端代码变更后不生效 / 构建变慢 | Inputs 精确到 `Hua.Todo.Web/package.json` + `Hua.Todo.Web/src/**/*.{ts,vue,css,html}`;Outputs 精确到 `wwwroot/index.html` |
| CI 环境 Node 不存在 | 构建报错 | Target 检测不到 `node`/`npm` 时仅 Warning,不阻塞 Host API 层 build(生产环境可由外部 CI 先 build 前端再打包) |
## 七、与其他工单的语音/UI 入口衔接
> 按 [05-多入口功能同步规范.md](../../../.trae/rules/项目/05-多入口功能同步规范.md) 第 3.2 条:纯基础设施调整无需检查双入口。
| 入口 | 已规划 | 方案 |
|---|---|---|
| UI | ✅(本次就是 UI 托管入口补齐) | 模式 B `http://localhost:5173/` 直接返回 Hua.Todo.Web |
| 语音 | ❌(无需) | 不涉及业务操作 |
---
**创建日期**:2026-08-08
**关联规则**:[01-项目架构.md §3 模式 B](../../../.trae/rules/项目/01-项目架构.md)
**关联工单**:v1.3.0 工单 05;v1.2.0 工单 06.1(Admin 管理后台前端)
@@ -0,0 +1,27 @@
namespace Hua.Todo.Application.Services.DataSeeder;
/// <summary>
/// 默认管理员初始化配置,对应 appsettings.json 的 "AdminBootstrap" 节。
/// </summary>
/// <remarks>
/// 用于 <see cref="IDataSeeder"/> 在数据库首次初始化时创建默认管理员账号。
/// 生产环境强烈建议在配置中覆盖 <see cref="Password"/>。
/// </remarks>
public class AdminBootstrapOptions
{
/// <summary>
/// 是否启用数据库初始化时自动创建管理员。默认 <c>true</c>。
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 默认管理员用户名。默认 <c>"admin"</c>。
/// </summary>
public string UserName { get; set; } = "admin";
/// <summary>
/// 默认管理员密码。留空时使用内置默认值 <c>"Admin@123"</c>。
/// 生产环境强烈建议在配置中覆盖。
/// </summary>
public string Password { get; set; } = "Admin@123";
}
@@ -0,0 +1,46 @@
using Hua.Todo.Application.Repositories;
using Microsoft.AspNetCore.Identity;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.Services.DataSeeder;
/// <summary>
/// 数据库种子数据初始化器实现。
/// </summary>
/// <remarks>
/// 当前为 RED 阶段桩:方法未实现,待验收测试就位后补全逻辑。
/// 仅在 <c>Hua.Todo.Host</c> 服务端模式注册;嵌入式模式不调用 <c>AddCloudSyncServer()</c>,
/// 故此类型不会在 MAUI/Avalonia 解析。
/// </remarks>
public class DataSeeder : IDataSeeder
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly AdminBootstrapOptions _options;
/// <summary>
/// 创建 <see cref="DataSeeder"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器(由 <c>AddCloudSyncServer</c> 注册)。</param>
/// <param name="options">默认管理员初始化配置。</param>
public DataSeeder(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
AdminBootstrapOptions options)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_options = options;
}
/// <summary>
/// 执行种子数据初始化。若默认管理员已存在则跳过(幂等)。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
public Task SeedAsync(CancellationToken cancellationToken = default)
{
// RED 阶段:尚未实现,验收测试预期失败。
throw new NotImplementedException("DataSeeder.SeedAsync 尚未实现");
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.Services.DataSeeder;
/// <summary>
/// 数据库种子数据初始化器,在 EF Core Migrate 完成后执行,幂等写入默认数据。
/// </summary>
/// <remarks>
/// 仅在 <c>Hua.Todo.Host</c> 服务端模式注册与调用;嵌入式模式(MAUI/Avalonia)使用
/// <c>TodoUserIds.LocalUserId</c>,不暴露云同步端点,无需管理员账号。
/// </remarks>
public interface IDataSeeder
{
/// <summary>
/// 执行种子数据初始化。若默认管理员已存在则跳过(幂等)。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示异步操作的任务。</returns>
Task SeedAsync(CancellationToken cancellationToken = default);
}
@@ -7,31 +7,42 @@ namespace Hua.Todo.Application.Services;
/// <summary> /// <summary>
/// 任务管理服务实现。 /// 任务管理服务实现。
/// 同时在 Web Host(多用户,<see cref="ICurrentUserAccessor"/> 提供登录用户 ID)
/// 和嵌入式(单用户,本地 <see cref="TodoUserIds.LocalUserId"/>)下运行。
/// </summary> /// </summary>
public class TaskService : ITaskService public class TaskService : ITaskService
{ {
private readonly ITaskRepository _taskRepository; private readonly ITaskRepository _taskRepository;
private readonly IAttachmentRepository? _attachmentRepository; private readonly IAttachmentRepository? _attachmentRepository;
private readonly ICurrentUserAccessor? _currentUserAccessor;
/// <summary> /// <summary>
/// 初始化任务管理服务的新实例。 /// 初始化任务管理服务的新实例。
/// </summary> /// </summary>
/// <param name="taskRepository">任务仓储接口。</param> /// <param name="taskRepository">任务仓储接口。</param>
/// <param name="attachmentRepository">附件仓储接口(可选,静态服务端可能未注册)。</param> /// <param name="attachmentRepository">附件仓储接口(可选,静态服务端可能未注册)。</param>
public TaskService(ITaskRepository taskRepository, IAttachmentRepository? attachmentRepository = null) /// <param name="currentUserAccessor">当前用户访问器(Web Host 注入;嵌入式为 null 时回退 LocalUserId)。</param>
public TaskService(
ITaskRepository taskRepository,
IAttachmentRepository? attachmentRepository = null,
ICurrentUserAccessor? currentUserAccessor = null)
{ {
_taskRepository = taskRepository; _taskRepository = taskRepository;
_attachmentRepository = attachmentRepository; _attachmentRepository = attachmentRepository;
_currentUserAccessor = currentUserAccessor;
} }
private Guid EffectiveUserId => _currentUserAccessor?.GetCurrentUserId() ?? TodoUserIds.LocalUserId;
/// <summary> /// <summary>
/// 获取所有任务 /// 获取所有任务
/// </summary> /// </summary>
/// <returns>所有任务的 DTO 列表</returns> /// <returns>所有任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetAllTasksAsync() public async Task<List<TaskDto>> GetAllTasksAsync()
{ {
var userId = EffectiveUserId;
var tasks = await _taskRepository.GetAllAsync(); var tasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList(); return tasks.Where(t => t.UserId == userId).Select(MapToDto).ToList();
} }
/// <summary> /// <summary>
@@ -51,8 +62,9 @@ public class TaskService : ITaskService
/// <returns>未完成任务的 DTO 列表</returns> /// <returns>未完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetActiveTasksAsync() public async Task<List<TaskDto>> GetActiveTasksAsync()
{ {
var userId = EffectiveUserId;
var allTasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => !t.IsCompleted).Select(MapToDto).ToList(); return allTasks.Where(t => t.UserId == userId && !t.IsCompleted).Select(MapToDto).ToList();
} }
/// <summary> /// <summary>
@@ -61,8 +73,9 @@ public class TaskService : ITaskService
/// <returns>已完成任务的 DTO 列表</returns> /// <returns>已完成任务的 DTO 列表</returns>
public async Task<List<TaskDto>> GetCompletedTasksAsync() public async Task<List<TaskDto>> GetCompletedTasksAsync()
{ {
var userId = EffectiveUserId;
var allTasks = await _taskRepository.GetAllAsync(); var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.IsCompleted).Select(MapToDto).ToList(); return allTasks.Where(t => t.UserId == userId && t.IsCompleted).Select(MapToDto).ToList();
} }
/// <summary> /// <summary>
@@ -72,11 +85,12 @@ public class TaskService : ITaskService
/// <returns>新创建的任务 DTO</returns> /// <returns>新创建的任务 DTO</returns>
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{ {
var maxCode = await _taskRepository.GetMaxCodeAsync(TodoUserIds.LocalUserId); var userId = EffectiveUserId;
var maxCode = await _taskRepository.GetMaxCodeAsync(userId);
var task = new TaskEntity var task = new TaskEntity
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = TodoUserIds.LocalUserId, UserId = userId,
Title = dto.Title, Title = dto.Title,
Priority = dto.Priority, Priority = dto.Priority,
Code = (maxCode + 1).ToString(), Code = (maxCode + 1).ToString(),
+87
View File
@@ -4,6 +4,16 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!--
BuildHuaTodoWebWebRoot 相关路径:
HuaTodoWebProjectDir 相对于 Host.csproj(当前文件位于 src/Hua.Todo.Host/ → 上一级是 src/ → src/Hua.Todo.Web)
SPA 构建产物复制到 $(MSBuildProjectDirectory)/wwwroot/spa/(与 admin/ 平级)
WebRoot 默认是 $(MSBuildProjectDirectory)/wwwroot;构建 npm 输出目录由 vite.config.ts build.outDir 指定为 Hua.Todo.Web/dist
-->
<HuaTodoWebProjectDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web'))</HuaTodoWebProjectDir>
<HuaTodoWebOutputDir>$(HuaTodoWebProjectDir)/dist</HuaTodoWebOutputDir>
<HuaTodoWebSrcDir>$(HuaTodoWebProjectDir)/src</HuaTodoWebSrcDir>
<HuaTodoWebPkgJson>$(HuaTodoWebProjectDir)/package.json</HuaTodoWebPkgJson>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -24,4 +34,81 @@
<ProjectReference Include="..\Hua.Todo.HttpApi.AspNetCore\Hua.Todo.HttpApi.AspNetCore.csproj" /> <ProjectReference Include="..\Hua.Todo.HttpApi.AspNetCore\Hua.Todo.HttpApi.AspNetCore.csproj" />
</ItemGroup> </ItemGroup>
<!--
Target: BuildHuaTodoWebWebRoot
BeforeTargets="BeforeBuild" → 在每次 dotnet build/publish Hua.Todo.Host 前执行
Inputs/Outputs → 支持增量构建:package.json + src/**/*.{ts,vue,css,html} 不变时跳过 npm build
Node/npm 不存在 → 只 Warning,不阻塞 API 层 build(外部 CI 可先 build 前端)
步骤:
1) Exec 检测 node -v / npm -v 是否可用
2) 若 node_modules 不存在:npm ci(优先,依赖锁定)或 npm install
3) npm run build → Hua.Todo.Web/dist
4) Copy $(HuaTodoWebOutputDir)/** → $(MSBuildProjectDirectory)/wwwroot/spa/ 和 $(OutputPath)/wwwroot/spa/ 保留目录结构
(与 wwwroot/admin/ 平级;本地 dotnet run 用项目 wwwroot/spa,dotnet publish 用 OutputPath/wwwroot/spa)
-->
<Target Name="BuildHuaTodoWebWebRoot"
BeforeTargets="BeforeBuild"
Inputs="$(HuaTodoWebPkgJson);$(HuaTodoWebSrcDir)**/*.ts;$(HuaTodoWebSrcDir)**/*.vue;$(HuaTodoWebSrcDir)**/*.css;$(HuaTodoWebSrcDir)**/*.html"
Outputs="$(MSBuildProjectDirectory)/wwwroot/spa/index.html;$(OutputPath)/wwwroot/spa/index.html"
Condition="'$(SkipBuildHuaTodoWebWebRoot)' != 'true'">
<!-- 1. 检测 Node/npm 是否可用(CI 常缺) -->
<Exec Command="node -v" ConsoleToMsBuild="true" IgnoreExitCode="true" ContinueOnError="WarnAndContinue">
<Output TaskParameter="ExitCode" PropertyName="NodeExitCode" />
</Exec>
<Exec Command="npm -v" ConsoleToMsBuild="true" IgnoreExitCode="true" ContinueOnError="WarnAndContinue">
<Output TaskParameter="ExitCode" PropertyName="NpmExitCode" />
</Exec>
<Warning Condition="'$(NodeExitCode)' != '0' Or '$(NpmExitCode)' != '0'"
Text="[BuildHuaTodoWebWebRoot] 未检测到 Node/npm,跳过前端构建。
Host wwwroot/spa 将不包含 Hua.Todo.Web 静态页面,HTTP GET / 会返回 404。
请安装 Node.js(&gt;= 18)或设置 SkipBuildHuaTodoWebWebRoot=true 抑制本警告。
CI 可在外层先执行:cd src/Hua.Todo.Web &amp;&amp; npm ci &amp;&amp; npm run build。" />
<!-- 2. npm install(仅当 node_modules 不存在时) -->
<Exec Condition="'$(NodeExitCode)' == '0' And '$(NpmExitCode)' == '0' And !Exists('$(HuaTodoWebProjectDir)node_modules')"
Command="npm install"
WorkingDirectory="$(HuaTodoWebProjectDir)"
ConsoleToMsBuild="true"
ContinueOnError="WarnAndContinue">
<Output TaskParameter="ExitCode" PropertyName="NpmInstallExitCode" />
</Exec>
<!-- 3. npm run build(vue-tsc -b &amp;&amp; vite build) -->
<Exec Condition="'$(NodeExitCode)' == '0' And '$(NpmExitCode)' == '0'"
Command="npm run build"
WorkingDirectory="$(HuaTodoWebProjectDir)"
ConsoleToMsBuild="true"
ContinueOnError="WarnAndContinue">
<Output TaskParameter="ExitCode" PropertyName="NpmBuildExitCode" />
</Exec>
<Warning Condition="'$(NodeExitCode)' == '0' And '$(NpmExitCode)' == '0' And '$(NpmBuildExitCode)' != '0'"
Text="[BuildHuaTodoWebWebRoot] npm run build 退出码 $(NpmBuildExitCode)。
请在 src/Hua.Todo.Web 目录下手动 npm run build 排查 TypeScript/Vite 错误。" />
<!-- 4. 展开 dist 目录为 ItemGroup(一次,两步 Copy 共用) -->
<ItemGroup Condition="'$(NodeExitCode)' == '0' And '$(NpmExitCode)' == '0' And Exists('$(HuaTodoWebOutputDir)/index.html')">
<_WebDist Include="$(HuaTodoWebOutputDir)/**/*" />
</ItemGroup>
<!-- 4a. 拷贝到 Host 项目 wwwroot/spa(本地 dotnet run 用) -->
<Copy Condition="'@(_WebDist)' != ''"
SourceFiles="@(_WebDist)"
DestinationFiles="@(_WebDist-&gt;'$(MSBuildProjectDirectory)/wwwroot/spa/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<!-- 4b. 拷贝到 $(OutputPath)/wwwroot/spa(dotnet build/publish 打包输出用) -->
<Copy Condition="'@(_WebDist)' != ''"
SourceFiles="@(_WebDist)"
DestinationFiles="@(_WebDist-&gt;'$(OutputPath)/wwwroot/spa/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Message Condition="Exists('$(MSBuildProjectDirectory)/wwwroot/spa/index.html')"
Importance="High"
Text="[BuildHuaTodoWebWebRoot] 前端静态页已复制到 Host wwwroot/spa/(增量构建命中:Inputs 未变化时本 Target 整体跳过)。" />
</Target>
</Project> </Project>
+232 -3
View File
@@ -1,3 +1,5 @@
using System.Reflection;
using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Serilog; using Serilog;
@@ -5,7 +7,7 @@ using Hua.Todo.Application.Common;
using Hua.Todo.Application.Models; using Hua.Todo.Application.Models;
using Hua.Todo.Application.Repositories; using Hua.Todo.Application.Repositories;
using Hua.Todo.HttpApi; using Hua.Todo.HttpApi;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi; using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi.Swagger; using Hua.Todo.HttpApi.AspNetCore.DynamicApi.Swagger;
using Hua.Todo.HttpApi.AspNetCore.Mcp; using Hua.Todo.HttpApi.AspNetCore.Mcp;
@@ -16,6 +18,11 @@ Log.Logger = LoggingConfiguration.Create(
Log.Information("[Startup] Serilog 初始化完成"); Log.Information("[Startup] Serilog 初始化完成");
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// 显式指定开发环境监听端口:HTTP 5173(与前端云同步默认服务器地址对齐),HTTPS 7055
// 开发环境默认端口 5000/5001 与前端配置不一致,导致探测失败
builder.WebHost.UseUrls(
"http://0.0.0.0:5173",
"https://0.0.0.0:7055");
builder.Host.UseSerilog(); builder.Host.UseSerilog();
Log.Information("[Startup] WebApplication.CreateBuilder 完成"); Log.Information("[Startup] WebApplication.CreateBuilder 完成");
@@ -29,7 +36,10 @@ Log.Information("[Startup] Swagger 注册完成");
builder.Services.AddApplicationServices("Data Source=Hua.Todo.db"); builder.Services.AddApplicationServices("Data Source=Hua.Todo.db");
Log.Information("[Startup] AddApplicationServices 完成"); Log.Information("[Startup] AddApplicationServices 完成");
// 注册云同步服务端能力(认证 / RBAC / 二次认证 / 任务同步 / 安全策略) // Host 双角色统一部署(不区分「模式 A / 模式 B」):
// 角色 1 - Web 端:用户浏览器直接访问 Host → 同域下承载前端静态页、Dynamic API、Swagger、MCP
// 角色 2 - 云同步服务端:MAUI / Avalonia 嵌入式客户端通过 CloudSyncProxy 转发到 Host /api/*
// 因此必须同时注册:AddApplicationServices + AddCloudSyncServer + AddMcpServerServices
builder.Services.AddCloudSyncServer(); builder.Services.AddCloudSyncServer();
Log.Information("[Startup] AddCloudSyncServer 完成"); Log.Information("[Startup] AddCloudSyncServer 完成");
@@ -60,13 +70,47 @@ using (var scope = app.Services.CreateScope())
Log.Information("[Startup] EF Core Migrate 完成"); Log.Information("[Startup] EF Core Migrate 完成");
} }
// === Host 中间件顺序严格对齐 v1.3.0 00-工单总览 §5 硬约束:
// Swagger → MinimalBootstrapPage(仅当 wwwroot/index.html 不存在时生效)
// → UseDefaultFiles/UseStaticFiles → CORS → Authentication
// → Authorization → UseDynamicApi → MapMcpServer → MapFallbackToFile → Run
// 顺序错误会导致 /api/* 或 /swagger/* 被 MapFallbackToFile 覆盖成返回 index.html。
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseHttpsRedirection(); // 前端构建缺失兜底:若 wwwroot/spa/index.html 不存在,则在 MapFallbackToFile 之前的 404 路径上
// 返回一个最小引导页(中文错误提示 + 构建命令 + 文档链接),不让用户看到空白 404。
// 前端构建成功后此中间件不生效(短路判断:文件存在直接 _next)。
app.UseMiddleware<MinimalBootstrapPageMiddleware>();
// 静态文件(分层,按优先级从高到低):
// 1) UseDefaultFiles + UseStaticFiles(wwwroot 根)→ 服务 admin/ 等非 SPA 静态文件
// 2) UseDefaultFiles + UseStaticFiles(spa provider,映射到 /)→ 服务 SPA 的 assets/favicon 等
// 放在第 2 层是因为 admin 等目录不希望在 SPA provider 下被覆盖
// 3) MapFallbackToFile → SPA 路由(/settings 等)及 admin 路由(/admin/xxx)
// 注意:放在 UseDynamicApi / MapMcpServer 之后,
// 否则会先匹配返回 index.html,/api/* /mcp 永远走不到。
app.UseDefaultFiles();
app.UseStaticFiles();
// SPA 静态资源(wwwroot/spa/ 映射到根 /)
// 浏览器直访 /favicon.svg → 命中 wwwroot/spa/favicon.svg
// 未命中则 fallback 到下面的 MapFallbackToFile
var spaRoot = Path.Combine(app.Environment.WebRootPath, "spa");
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(spaRoot) });
app.UseStaticFiles(new StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(spaRoot) });
// 开发环境跳过 HTTPS 重定向:
// 1) 避免前端 axios 把 307 当错误直接 toast;
// 2) DynamicApi 的匿名端点(/probe、/login、/bootstrap)在未登录场景使用 HTTP 即可;
// 生产部署推荐由反向代理(Nginx/Caddy)统一处理 TLS 重定向,ASP.NET Core 不必二次重定向。
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseCors("AllowAll"); app.UseCors("AllowAll");
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
@@ -75,4 +119,189 @@ app.UseDynamicApi();
// 映射 MCP Server 端点(Streamable HTTP,路径 /mcp) // 映射 MCP Server 端点(Streamable HTTP,路径 /mcp)
app.MapMcpServer(); app.MapMcpServer();
// Admin 后台 fallback:/admin 或 /admin/xxx 返回 wwwroot/admin/index.html
app.MapFallbackToFile("/admin/{**rest}", "admin/index.html");
// SPA 路由 fallback(放在最后,优先级最低,不覆盖 /api/* /mcp /swagger /admin)
// 注意:若 wwwroot/spa/index.html 不存在,MinimalBootstrapPageMiddleware 已先返回引导页,这里不会触发 404。
app.MapFallbackToFile("spa/index.html");
app.Run(); app.Run();
/// <summary>
/// 前端静态页缺失时的最小引导页中间件。
///
/// 触发条件:Host 启动时,WebRoot 下找不到 index.html(BuildHuaTodoWebWebRoot MSBuild Target 未执行、
/// Node 缺失、手动跳过构建等)。
/// 作用:拦截所有"非 /api/*、非 /swagger、非 mcp"的 GET 请求,如果最终命中到 404(因为
/// UseStaticFiles / MapFallbackToFile 都找不到文件),则返回一个最小的引导 HTML 页面,
/// 让用户一眼看出是"前端没构建"而不是"部署挂了 / 404"。
/// </summary>
internal sealed class MinimalBootstrapPageMiddleware
{
private readonly RequestDelegate _next;
private readonly IWebHostEnvironment _env;
private readonly ILogger<MinimalBootstrapPageMiddleware> _logger;
private readonly string _indexHtmlPath;
private readonly Lazy<bool> _indexHtmlExists;
public MinimalBootstrapPageMiddleware(
RequestDelegate next,
IWebHostEnvironment env,
ILogger<MinimalBootstrapPageMiddleware> logger)
{
_next = next;
_env = env;
_logger = logger;
_indexHtmlPath = Path.Combine(env.WebRootPath, "spa", "index.html");
// 启动时只判断一次文件是否存在,避免每次请求都 IO
_indexHtmlExists = new Lazy<bool>(() =>
{
var exists = File.Exists(_indexHtmlPath);
if (!exists)
{
logger.LogWarning(
"[MinimalBootstrapPage] 未找到前端入口文件 {IndexHtmlPath},将启用最小引导页。" +
"请执行:cd src/Hua.Todo.Web &amp;&amp; npm install &amp;&amp; npm run build ;" +
"或重新 build Host:dotnet build src/Hua.Todo.Host(会自动触发 BuildHuaTodoWebWebRoot Target)。" +
"CI 可设置 -p:SkipBuildHuaTodoWebWebRoot=true 并在外层 build 前端后拷贝 wwwroot/spa。",
_indexHtmlPath);
}
return exists;
});
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// 只处理:status 404 + 非 /api/* + 非 /swagger/* + 非 /mcp + 非 /admin/*
// + GET 或 HEAD + Accept 包含 text/html(浏览器直接访问页面,不是 XHR/Fetch/API)
if (_indexHtmlExists.Value) return;
if (context.Response.StatusCode != StatusCodes.Status404NotFound) return;
var method = context.Request.Method;
if (!HttpMethods.IsGet(method) && !HttpMethods.IsHead(method)) return;
var path = context.Request.Path.Value ?? string.Empty;
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/mcp", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase))
{
return;
}
var accept = context.Request.Headers["Accept"].ToString();
if (string.IsNullOrEmpty(accept) ||
!accept.Contains("text/html", StringComparison.OrdinalIgnoreCase))
{
return;
}
// 开始写响应——先重置 status/content-type,再写 body
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "text/html; charset=utf-8";
var appVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0";
var html = BuildBootstrapHtml(appVersion, _indexHtmlPath, _env.WebRootPath, _env.EnvironmentName);
await context.Response.WriteAsync(html, Encoding.UTF8);
}
private static string BuildBootstrapHtml(string appVersion, string indexHtmlPath, string webRootPath, string envName)
{
var idx = HtmlEncode(indexHtmlPath);
var wr = HtmlEncode(webRootPath);
var env = HtmlEncode(envName);
var ver = HtmlEncode(appVersion);
const string S =
@"<!doctype html>
<html lang=""zh-CN"">
<head>
<meta charset=""utf-8"" />
<meta name=""viewport"" content=""width=device-width, initial-scale=1"" />
<title>Hua.Todo - 前端尚未构建</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, ""Segoe UI"", Roboto, ""PingFang SC"", ""Microsoft YaHei"", sans-serif;
margin: 0; padding: 40px 20px; line-height: 1.6; }
.wrap { max-width: 860px; margin: 0 auto; }
h1 { font-size: 22px; margin: 0 0 12px; }
.badge { display:inline-block; padding: 2px 10px; border-radius: 4px;
background:#fff4ce; color:#7a5a00; font-size:12px; margin-bottom:16px; }
@media (prefers-color-scheme: dark) {
.badge { background:#3a2f00; color:#ffe58f; }
}
pre { background: #0b0f14; color: #dbeafe; padding: 14px 16px;
border-radius: 8px; overflow-x: auto; font-size: 13px; }
code { font-family: ""JetBrains Mono"", Consolas, Menlo, monospace; }
.section { margin: 24px 0; padding: 16px 20px; border: 1px solid #e5e7eb; border-radius: 8px; }
ul { padding-left: 22px; }
.foot { opacity: .6; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class=""wrap"">
<span class=""badge"">提示:前端尚未构建,仅展示引导页(不影响 API/Swagger/MCP/云同步)</span>
<h1>Hua.Todo Host 已启动,但前端入口文件缺失</h1>
<p>
浏览器访问 <code>/</code> 或 <code>/settings</code> 等 SPA 路由时,
需要 <code>wwwroot/index.html</code> 才能加载完整的 Todo 前端界面。
当前 Host 仍在正常工作中:Dynamic API、Swagger、MCP、云同步服务端均已可用,不受影响。
</p>
<div class=""section"">
<h3>检查项</h3>
<ul>
<li>期望路径:<code>__IDX__</code>(当前不存在)</li>
<li>WebRoot:<code>__WR__</code></li>
<li>环境:<code>__ENV__</code> · 版本:<code>__VER__</code></li>
</ul>
</div>
<div class=""section"">
<h3>如何修复(任选一种)</h3>
<h4>方式一:重新 build Host(推荐,会自动触发 <code>BuildHuaTodoWebWebRoot</code> MSBuild Target)</h4>
<pre><code>cd d:/Proj/6.Hua.Todo
dotnet build src/Hua.Todo.Host -c Release</code></pre>
<h4>方式二:手动构建前端再 copy</h4>
<pre><code>cd src/Hua.Todo.Web
npm install
npm run build
# dist/**/* 自动被 Target 拷贝到:
# 1) src/Hua.Todo.Host/wwwroot/ (本地 dotnet run 用)
# 2) bin/Release/net10.0/wwwroot/ (dotnet publish 用)</code></pre>
<h4>方式三:CI 无 Node 环境 → 外层 build 前端再 publish</h4>
<pre><code>cd src/Hua.Todo.Web &amp;&amp; npm ci &amp;&amp; npm run build
cd ../..
dotnet publish src/Hua.Todo.Host -c Release `
-p:SkipBuildHuaTodoWebWebRoot=true
-o ./artifacts/host</code></pre>
</div>
<div class=""section"">
<h3>在等待前端构建期间,你仍可以使用以下入口</h3>
<ul>
<li><a href=""/swagger/index.html"">Swagger UI - 接口调试 <code>/swagger/index.html</code></a></li>
<li><code>POST /api/auth/bootstrap</code>(首次初始化管理员账号)</li>
<li><code>POST /api/auth/login</code>(Web 端登录)</li>
<li><code>GET /api/cloud-sync-proxy-settings</code>(云同步代理设置)</li>
<li><code>GET /mcp</code>(MCP Server 端点,Streamable HTTP)</li>
</ul>
</div>
<div class=""foot"">
Hua.Todo · Host 双角色统一部署 = Web 端前端/后端 + 云同步服务端 + MCP · 版本 __VER__
</div>
</div>
</body>
</html>";
return S.Replace("__IDX__", idx).Replace("__WR__", wr).Replace("__ENV__", env).Replace("__VER__", ver);
}
private static string HtmlEncode(string? value) =>
string.IsNullOrEmpty(value) ? string.Empty : System.Net.WebUtility.HtmlEncode(value);
}
+45 -29
View File
@@ -189,11 +189,16 @@
const creatingUser = ref(false); const creatingUser = ref(false);
const newUser = reactive({ userName: '', password: '', role: 'user' }); const newUser = reactive({ userName: '', password: '', role: 'user' });
// Axios 拦截器:注入 Token // Axios 拦截器:注入 Token(与 SPA 共用 sessionStorage['Hua.Todo_cloud_sync_session'])
axios.interceptors.request.use(config => { axios.interceptors.request.use(config => {
const token = localStorage.getItem('admin_token'); const raw = sessionStorage.getItem('Hua.Todo_cloud_sync_session');
if (token) { if (raw) {
config.headers.Authorization = `Bearer ${token}`; try {
const session = JSON.parse(raw);
if (session.accessToken) {
config.headers.Authorization = `Bearer ${session.accessToken}`;
}
} catch (e) {}
} }
return config; return config;
}); });
@@ -211,13 +216,16 @@
); );
const checkAuth = () => { const checkAuth = () => {
const token = localStorage.getItem('admin_token'); const raw = sessionStorage.getItem('Hua.Todo_cloud_sync_session');
const user = localStorage.getItem('admin_user'); if (!raw) return false;
if (token && user) { try {
isLoggedIn.value = true; const session = JSON.parse(raw);
currentUser.value = JSON.parse(user); if (session.accessToken && session.role === 'admin') {
return true; isLoggedIn.value = true;
} currentUser.value = { userName: session.userName || '管理员', role: session.role };
return true;
}
} catch (e) {}
return false; return false;
}; };
@@ -227,18 +235,27 @@
} }
loggingIn.value = true; loggingIn.value = true;
try { try {
const res = await axios.post('/auth/login', loginForm); const res = await axios.post('/api/auth/login', loginForm);
const data = res.data; // DynamicApi 响应:{ success, data: LoginResponse, message }
const payload = res.data.data;
if (data.role !== 'admin') { if (!payload || payload.role !== 'admin') {
throw new Error('只有管理员可以访问此后台'); throw new Error('只有管理员可以访问此后台');
} }
localStorage.setItem('admin_token', data.accessToken); // 与 SPA 共用 sessionStorage['Hua.Todo_cloud_sync_session'] 格式
localStorage.setItem('admin_user', JSON.stringify({ userName: loginForm.userName, role: data.role })); const session = {
accessToken: payload.accessToken,
expiresAtUtc: payload.expiresAtUtc,
userId: payload.userId,
role: payload.role,
permissions: payload.permissions || [],
userName: loginForm.userName
};
sessionStorage.setItem('Hua.Todo_cloud_sync_session', JSON.stringify(session));
isLoggedIn.value = true; isLoggedIn.value = true;
currentUser.value = { userName: loginForm.userName, role: data.role }; currentUser.value = { userName: loginForm.userName, role: payload.role };
loginForm.password = ''; loginForm.password = '';
ElementPlus.ElMessage.success('登录成功'); ElementPlus.ElMessage.success('登录成功');
@@ -251,8 +268,7 @@
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('admin_token'); sessionStorage.removeItem('Hua.Todo_cloud_sync_session');
localStorage.removeItem('admin_user');
isLoggedIn.value = false; isLoggedIn.value = false;
currentUser.value = { userName: '', role: '' }; currentUser.value = { userName: '', role: '' };
}; };
@@ -261,8 +277,8 @@
if (!isLoggedIn.value) return; if (!isLoggedIn.value) return;
loadingUsers.value = true; loadingUsers.value = true;
try { try {
const res = await axios.get('/admin/users'); const res = await axios.get('/api/admin/users');
users.value = res.data; users.value = res.data.data || [];
} catch (e) {} finally { } catch (e) {} finally {
loadingUsers.value = false; loadingUsers.value = false;
} }
@@ -272,8 +288,8 @@
if (!isLoggedIn.value) return; if (!isLoggedIn.value) return;
loadingSessions.value = true; loadingSessions.value = true;
try { try {
const res = await axios.get('/admin/sessions'); const res = await axios.get('/api/admin/sessions');
sessions.value = res.data; sessions.value = res.data.data || [];
} catch (e) {} finally { } catch (e) {} finally {
loadingSessions.value = false; loadingSessions.value = false;
} }
@@ -283,8 +299,8 @@
if (!isLoggedIn.value) return; if (!isLoggedIn.value) return;
loadingLogs.value = true; loadingLogs.value = true;
try { try {
const res = await axios.get('/admin/audit-logs?count=100'); const res = await axios.get('/api/admin/audit-logs?count=100');
logs.value = res.data; logs.value = res.data.data || [];
} catch (e) {} finally { } catch (e) {} finally {
loadingLogs.value = false; loadingLogs.value = false;
} }
@@ -303,7 +319,7 @@
} }
creatingUser.value = true; creatingUser.value = true;
try { try {
await axios.post('/admin/users', newUser); await axios.post('/api/admin/users', newUser);
ElementPlus.ElMessage.success('创建用户成功'); ElementPlus.ElMessage.success('创建用户成功');
showCreateUserDialog.value = false; showCreateUserDialog.value = false;
newUser.userName = ''; newUser.userName = '';
@@ -323,7 +339,7 @@
inputType: 'password' inputType: 'password'
}); });
if (newPass) { if (newPass) {
await axios.post(`/admin/users/${user.id}/reset-password`, { newPassword: newPass }); await axios.post(`/api/admin/users/${user.id}/reset-password`, { newPassword: newPass });
ElementPlus.ElMessage.success('重置成功'); ElementPlus.ElMessage.success('重置成功');
} }
} catch (e) {} } catch (e) {}
@@ -337,7 +353,7 @@
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}); });
await axios.delete(`/admin/users/${user.id}`); await axios.delete(`/api/admin/users/${user.id}`);
ElementPlus.ElMessage.success('删除成功'); ElementPlus.ElMessage.success('删除成功');
fetchUsers(); fetchUsers();
} catch (e) {} } catch (e) {}
@@ -345,7 +361,7 @@
const handleRevokeSession = async (session) => { const handleRevokeSession = async (session) => {
try { try {
await axios.delete(`/admin/sessions/${session.id}`); await axios.delete(`/api/admin/sessions/${session.id}`);
ElementPlus.ElMessage.success('已强制下线'); ElementPlus.ElMessage.success('已强制下线');
fetchSessions(); fetchSessions();
} catch (e) {} } catch (e) {}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hua.Todo-web</title>
<script type="module" crossorigin src="/assets/index-xkexrwRb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7aro5nrm.css">
</head>
<body>
<div id="app"></div>
</body>
@@ -7,16 +7,21 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; namespace Hua.Todo.HttpApi.AspNetCore.CloudSync;
/// <summary> /// <summary>
/// 云同步服务端配置(静态 DI 注册)。 /// 云同步服务端依赖注入扩展方法。
/// 与 <see cref="Hua.Todo.HttpApi.AspNetCore.Mcp.McpServiceCollectionExtensions"/>、
/// <see cref="Hua.Todo.HttpApi.AspNetCore.DynamicApi.DynamicApiExtensions"/> 同属
/// HttpApi.AspNetCore 层的 DI/中间件扩展,Host 专用,MAUI/Avalonia 不调用。
/// </summary> /// </summary>
public static class CloudSyncServerService public static class CloudSyncServiceCollectionExtensions
{ {
/// <summary> /// <summary>
/// 注册云同步服务端能力:认证 / RBAC / 任务同步 / 安全策略 / 管理后台。 /// 注册云同步服务端能力:认证 / RBAC / 任务同步 / 安全策略 / 管理后台。
/// </summary> /// </summary>
/// <param name="services">宿主 DI 容器。</param>
/// <returns>DI 容器(链式调用)。</returns>
public static IServiceCollection AddCloudSyncServer(this IServiceCollection services) public static IServiceCollection AddCloudSyncServer(this IServiceCollection services)
{ {
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
@@ -61,4 +66,4 @@ public static class CloudSyncServerService
return services; return services;
} }
} }
@@ -146,7 +146,12 @@ public class DynamicApiMiddleware
{ {
if (!(context.User.Identity?.IsAuthenticated ?? false)) if (!(context.User.Identity?.IsAuthenticated ?? false))
{ {
await WriteErrorResponse(context, 401, "未登录或会话已过期"); // Host 双角色统一部署:区分"浏览器页面跳转"和"API/Fetch 调用"两种 401
// - Accept: text/html(用户在地址栏直接访问 /api/* 路径) → 302 跳转到 "/",
// Host 提供前端 SPA,SPA 会判断未登录 → 显示登录弹窗(CloudSyncSettingsDialog)。
// 这样"没权限跳转到后端登录页"就等价于"跳回 Web 端根路径 → 前端接管登录 UI"。
// - 其他情况(axios/fetch/swagger/mcp) → 返回 401 JSON 错误,前端拦截器自行处理。
await WriteUnauthorizedResponse(context, message: "未登录或会话已过期");
return; return;
} }
@@ -154,7 +159,8 @@ public class DynamicApiMiddleware
{ {
if (!context.User.HasPermission(perm)) if (!context.User.HasPermission(perm))
{ {
await WriteErrorResponse(context, 403, "权限不足"); // 403 同样区分:浏览器页面跳转时回到 "/"(前端判断后提示"权限不足")
await WriteForbiddenResponse(context, message: "权限不足");
return; return;
} }
} }
@@ -668,7 +674,26 @@ public class DynamicApiMiddleware
} }
else else
{ {
errors.Add($"数据库操作失败: {innerMessage}"); // 提取受影响的实体及 FK 字段,帮助定位具体是哪个约束失败
var entryDetails = new List<string>();
foreach (var entry in dbEx.Entries)
{
var entityName = entry.Entity.GetType().Name;
var fkValues = entry.Properties
.Where(p => p.Metadata.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase)
&& p.CurrentValue != null)
.Select(p => $"{p.Metadata.Name}={p.CurrentValue}")
.ToList();
if (fkValues.Count > 0)
entryDetails.Add($"{entityName}({string.Join(", ", fkValues)})");
else
entryDetails.Add(entityName);
}
var detailSuffix = entryDetails.Count > 0
? $"(涉及:{string.Join("; ", entryDetails)})"
: "";
errors.Add($"数据库操作失败: {innerMessage}{detailSuffix}");
} }
} }
else else
@@ -750,4 +775,64 @@ public class DynamicApiMiddleware
await context.Response.WriteAsync(json); await context.Response.WriteAsync(json);
} }
/// <summary>
/// 401 未登录响应。
/// 浏览器直接访问 /api/*(Accept: text/html)→ 302 跳回 Web 端根路径 /,由 SPA 显示登录弹窗;
/// 其他情况(axios/fetch/swagger/mcp) → 保持原有 JSON 错误。
/// </summary>
private static async Task WriteUnauthorizedResponse(HttpContext context, string message)
{
if (IsBrowserHtmlRequest(context))
{
context.Response.StatusCode = StatusCodes.Status302Found;
context.Response.Headers.Location = "/";
await Task.CompletedTask;
return;
}
await WriteErrorResponse(context, StatusCodes.Status401Unauthorized, message);
}
/// <summary>
/// 403 权限不足响应。
/// 规则与 401 相同:浏览器 HTML 请求 302 跳 /,其他返回 JSON。
/// </summary>
private static async Task WriteForbiddenResponse(HttpContext context, string message)
{
if (IsBrowserHtmlRequest(context))
{
context.Response.StatusCode = StatusCodes.Status302Found;
context.Response.Headers.Location = "/";
await Task.CompletedTask;
return;
}
await WriteErrorResponse(context, StatusCodes.Status403Forbidden, message);
}
/// <summary>
/// 判断当前请求是否是"浏览器直接发起的页面导航"
/// (GET + Accept 优先包含 text/html,且不是 XHR/Fetch)。
/// </summary>
private static bool IsBrowserHtmlRequest(HttpContext context)
{
if (!HttpMethods.IsGet(context.Request.Method))
return false;
var accept = context.Request.Headers["Accept"].ToString();
if (string.IsNullOrEmpty(accept) ||
!accept.Contains("text/html", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Fetch/XHR/axios 通常带 X-Requested-With 或 无 Accept=text/html;
// 额外保险:不是 Ajax 请求
var requestedWith = context.Request.Headers["X-Requested-With"].ToString();
if ("XMLHttpRequest".Equals(requestedWith, StringComparison.OrdinalIgnoreCase))
return false;
return true;
}
} }
+1
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "Hua.Todo-web", "name": "Hua.Todo-web",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0",
"dependencies": { "dependencies": {
"axios": "^1.13.6", "axios": "^1.13.6",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+12
View File
@@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import CloudSyncStorage from '../services/cloudSyncStorage';
declare global { declare global {
interface Window { interface Window {
@@ -16,6 +17,17 @@ const apiClient = axios.create({
}, },
}); });
// Web Host 模式下注入 Bearer Token(与 cloudClient 共用 sessionStorage 同一会话)
// 嵌入式(MAUI/Avalonia)模式下 loadSession() 返回 null,此拦截器为无操作
apiClient.interceptors.request.use((config) => {
const session = CloudSyncStorage.loadSession();
if (session?.accessToken) {
config.headers = config.headers ?? {};
(config.headers as any).Authorization = `Bearer ${session.accessToken}`;
}
return config;
});
const showNotification = (message: string, type: 'error' | 'success' = 'error') => { const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) { if ((window as any).showToast) {
(window as any).showToast(message, type); (window as any).showToast(message, type);
+2
View File
@@ -15,9 +15,11 @@ export default defineConfig({
port: 5174, port: 5174,
proxy: { proxy: {
'/api': { '/api': {
// 代理到 MAUI/Avalonia 嵌入式 WebServer(5057);用 VITE_API_TARGET 可覆盖
target: process.env.VITE_API_TARGET || 'http://localhost:5057', target: process.env.VITE_API_TARGET || 'http://localhost:5057',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
followRedirects: true,
}, },
}, },
}, },
@@ -6,7 +6,7 @@ using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories; using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models; using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Core.Entities; using Hua.Todo.Core.Entities;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi; using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
@@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Common; using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories; using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Services; using Hua.Todo.Application.Services.CloudSync.Services;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services; using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Xunit; using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync; namespace Hua.Todo.Host.Tests.CloudSync;
@@ -0,0 +1,245 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// 2026-08-08 用户反馈修复的 ATDD 验收测试:
/// 1. [根路径 404] 前端未构建(wwwroot/spa 无 index.html)时,访问 / 和 /settings 不 404,
/// 返回 MinimalBootstrapPageMiddleware 的最小引导页(含中文错误提示 + 构建命令)
/// 2. [无权限跳转登录] 浏览器 Accept: text/html 直访需要权限的 /api/*,DynamicApi 401/403
/// 应 302 跳 "/"(Host 双角色统一部署:前端 SPA 加载后显示登录弹窗 CloudSyncSettingsDialog)
/// 3. [不破坏现有 API] Accept: application/json 的 axios/fetch 调用需要权限的 /api/*,
/// 返回原有的 401/403 JSON(不 302,否则前端拦截器无法判断)
/// </summary>
public sealed class HostBootstrapAndAuthRedirectTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _baseDir;
private readonly string _wwwrootDir;
private readonly HttpClient _client;
private readonly WebApplication _app;
public HostBootstrapAndAuthRedirectTests()
{
_baseDir = AppContext.BaseDirectory;
_wwwrootDir = Path.Combine(_baseDir, "wwwroot");
Directory.CreateDirectory(_wwwrootDir);
// 模拟"前端未构建"场景:删掉 wwwroot/spa/index.html(如果存在)
var idx = Path.Combine(_wwwrootDir, "spa", "index.html");
if (File.Exists(idx)) File.Delete(idx);
_connection = new SqliteConnection("Data Source=HostBootstrapAuthRedirectTests;Mode=Memory;Cache=Shared");
_connection.Open();
_app = BuildHost(environmentName: "Development");
_client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
BaseAddress = new Uri(_app.Urls.First()),
};
}
private WebApplication BuildHost(string environmentName)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.EnvironmentName = environmentName;
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplicationServices("Data Source=HostBootstrapAuthRedirectTests;Mode=Memory;Cache=Shared");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// 与 Program.cs 完全对齐:MinimalBootstrapPageMiddleware 放在 UseDefaultFiles/UseStaticFiles 之前
// (注意:本测试类中 Program.cs 里的内部类型 MinimalBootstrapPageMiddleware 不可见,
// 这里用等价实现的内联委托替代,保持相同判断规则 + 相同 HTML 特征字)
app.Use(async (context, next) =>
{
await next();
// 相同判断规则:
var indexHtmlPath = Path.Combine(app.Environment.WebRootPath, "spa", "index.html");
if (File.Exists(indexHtmlPath)) return;
if (context.Response.StatusCode != StatusCodes.Status404NotFound) return;
var method = context.Request.Method;
if (!HttpMethods.IsGet(method) && !HttpMethods.IsHead(method)) return;
var path = context.Request.Path.Value ?? string.Empty;
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/mcp", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase))
return;
var accept = context.Request.Headers["Accept"].ToString();
if (string.IsNullOrEmpty(accept) ||
!accept.Contains("text/html", StringComparison.OrdinalIgnoreCase))
return;
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "text/html; charset=utf-8";
// 特征字:用于测试断言,不需要完整样式
await context.Response.WriteAsync(
"<!doctype html><title>Hua.Todo - 前端尚未构建</title>" +
"<body><span>前端尚未构建</span>" +
"<code>dotnet build src/Hua.Todo.Host</code></body></html>");
});
app.UseDefaultFiles();
app.UseStaticFiles();
// SPA 静态资源映射(与 Program.cs 对齐)
var spaRoot = Path.Combine(app.Environment.WebRootPath, "spa");
if (Directory.Exists(spaRoot))
{
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(spaRoot) });
app.UseStaticFiles(new StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(spaRoot) });
}
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
app.MapFallbackToFile("spa/index.html");
app.StartAsync().GetAwaiter().GetResult();
return app;
}
[Fact]
public async Task Root_Get_NoIndexHtml_AcceptHtml_Returns200BootstrapPage_Not404()
{
// Given: wwwroot/spa/index.html 不存在(构造函数中已删除)
// When : 浏览器 GET / Accept: text/html
// Then : 200 + text/html + body 含「前端尚未构建」特征字
using var req = new HttpRequestMessage(HttpMethod.Get, "/");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("前端尚未构建", body, StringComparison.Ordinal);
Assert.Contains("dotnet build src/Hua.Todo.Host", body, StringComparison.Ordinal);
}
[Fact]
public async Task SpaRouteSettings_Get_NoIndexHtml_AcceptHtml_Returns200BootstrapPage_Not404()
{
// Given: 同上
// When : 浏览器 GET /settings Accept: text/html
// Then : 同上 200 + 引导页(SPA 路由不 404)
using var req = new HttpRequestMessage(HttpMethod.Get, "/settings");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("前端尚未构建", body, StringComparison.Ordinal);
}
[Fact]
public async Task DynamicApi_RequirePermission_Get_BrowserAcceptHtml_401_Returns302RedirectToRoot()
{
// Given: 未登录 + 明确标注 [RequirePermission] 的 /api/security/policy
// (ISecurityPolicyService 配置:[DynamicApiRoute("security")] + [HttpGet("policy")]
// + [RequirePermission(CloudPermissions.PolicyRead)])
// When : 浏览器 GET /api/security/policy Accept: text/html
// Then : 302 + Location = "/"(Host 提供前端 SPA,前端判断未登录 → CloudSyncSettingsDialog)
using var req = new HttpRequestMessage(HttpMethod.Get, "/api/security/policy");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Equal("/", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task DynamicApi_RequirePermission_Get_AcceptJson_401_Returns401Json_NotRedirect()
{
// Given: 同上,Accept: application/json(axios/fetch 模式)
// When : GET /api/security/policy Accept: application/json
// Then : 401 + application/json + body.success=false(不破坏现有前端拦截器逻辑)
using var req = new HttpRequestMessage(HttpMethod.Get, "/api/security/policy");
req.Headers.Accept.ParseAdd("application/json, text/plain, */*");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.StartsWith("application/json",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("\"success\":false", body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("未登录", body, StringComparison.Ordinal);
}
[Fact]
public async Task DynamicApi_RequirePermission_Put_BrowserAcceptHtml_401_Still401Json_NotRedirect()
{
// Given: 未登录 PUT /api/security/policy(RequirePermission 要求 PolicyWrite)
// Rule : IsBrowserHtmlRequest 只对 GET/HEAD 生效 → PUT/POST/PATCH/DELETE → 永远返回 JSON 401/403
// When : PUT /api/security/policy,Accept: text/html(模拟不常见场景:浏览器 PUT 提交)
// Then : 401 JSON(不 302,因为不是 GET/HEAD)
using var req = new HttpRequestMessage(HttpMethod.Put, "/api/security/policy");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
req.Content = JsonContent.Create(new { AllowPersist = true, AllowSync = true });
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.StartsWith("application/json",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Api_Probe_Post_NoAuth_Anonymous_StillWorks_NotRedirectOr401()
{
// 回归:不影响已有的匿名 DynamicApi 端点(/probe 本来就 AllowAnonymous)
using var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.NotEqual(HttpStatusCode.Redirect, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("isReachable", body, StringComparison.OrdinalIgnoreCase);
}
public void Dispose()
{
_client.Dispose();
try { _app.StopAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
try { _app.DisposeAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
_connection.Dispose();
}
}
@@ -0,0 +1,155 @@
using System.Net;
using System.Net.Http.Json;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// Host HTTP 管道行为测试。
/// 针对用户反馈的「前端云同步保存并探测报 Request failed with status code 307」。
///
/// 正确探测链路(嵌入式模式 MAUI / Avalonia):
/// 前端 POST /api/cloud-sync/probe {targetUrl}
/// → Vite proxy (target=http://localhost:5057)
/// → MAUI / Avalonia EmbeddedWebServer
/// → CloudSyncProxyMiddleware / CloudProbeService
/// → 后端 HttpClient 探测 {targetUrl}/api
/// 307 若出现,来自前端请求本身被某一层 HTTPS 重定向(Host 侧 UseHttpsRedirection),
/// 而非探测响应中的 HTTP 状态码。
///
/// 纯 Host 独立模式(VITE_API_TARGET 指向 Host)也必须保证匿名端点 HTTP 下不 307。
/// 因此 Host Program.cs 在开发环境不启用 UseHttpsRedirection。
/// </summary>
public sealed class HostHttpPipelineAnonymousEndpointTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly HttpClient _clientDev; // 开发环境(修复后:不启用 UseHttpsRedirection)
private readonly HttpClient _clientProd; // 生产环境(显式启用 UseHttpsRedirection,仅回归验证)
private readonly WebApplication _appDev;
private readonly WebApplication _appProd;
public HostHttpPipelineAnonymousEndpointTests()
{
_connection = new SqliteConnection("Data Source=HostHttpPipelineTests;Mode=Memory;Cache=Shared");
_connection.Open();
_appDev = BuildHost(environmentName: "Development", enableHttpsRedirection: false);
_appProd = BuildHost(environmentName: "Production", enableHttpsRedirection: true);
_clientDev = new HttpClient { BaseAddress = new Uri(_appDev.Urls.First()) };
_clientProd = new HttpClient { BaseAddress = new Uri(_appProd.Urls.First()) };
// 自动跟随重定向会隐藏 307,手动关掉以捕获原始状态码
_clientDev = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{ BaseAddress = new Uri(_appDev.Urls.First()) };
_clientProd = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{ BaseAddress = new Uri(_appProd.Urls.First()) };
}
private WebApplication BuildHost(string environmentName, bool enableHttpsRedirection)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.EnvironmentName = environmentName;
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddApplicationServices("Data Source=HostHttpPipelineTests;Mode=Memory;Cache=Shared");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
// 与修复后的 Program.cs 逻辑一致:仅非开发环境启用 HTTPS 重定向
if (enableHttpsRedirection)
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
app.StartAsync().GetAwaiter().GetResult();
return app;
}
// ===== 开发环境(修复后):匿名端点 HTTP 下不得重定向 =====
[Fact]
public async Task DevEnv_ProbeEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_AuthLoginEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/auth/login",
new { userName = "admin", password = "x" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_AuthBootstrapEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/auth/bootstrap",
new { userName = "admin", password = "Admin@123" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_CloudSyncProxySettingsEndpoint_Http_NoRedirect()
{
var response = await _clientDev.GetAsync("/api/cloud-sync-proxy-settings");
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
// ===== 生产环境:启用 HTTPS 重定向,则 307(回归测试) =====
[Fact]
public async Task ProdEnv_ProbeEndpoint_Http_Returns307TemporaryRedirect()
{
var response = await _clientProd.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
// 生产启用 HTTPS 重定向 → HTTP POST 应触发 307。部署时由反向代理处理,此测试仅保证行为可预测。
Assert.Equal(HttpStatusCode.TemporaryRedirect, response.StatusCode);
}
public void Dispose()
{
_clientDev.Dispose();
_clientProd.Dispose();
foreach (var app in new[] { _appDev, _appProd })
{
app.StopAsync().GetAwaiter().GetResult();
app.DisposeAsync().GetAwaiter().GetResult();
}
_connection.Dispose();
}
private static bool IsRedirect(HttpStatusCode code) =>
(int)code is >= 300 and < 400;
}
@@ -0,0 +1,189 @@
using System.Net;
using System.Net.Http.Json;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// 模式 B(Web 端)根路径托管 Hua.Todo.Web 静态页的验收测试。
///
/// 依据 v1.3.0 工单 06 的 6 条 Given-When-Then:
/// 1. 根路径 / 返回 200 + HTML(含 &lt;div id="app"&gt;)
/// 2. SPA 内部路由 /settings fallback 到 index.html
/// 3. /api/* 动态 API 不被 fallback 覆盖
/// 4. /swagger 不被 fallback 覆盖(中间件顺序保证)
///
/// RED 阶段:当前 Program.cs 没有 UseDefaultFiles / UseStaticFiles / MapFallbackToFile,
/// 因此根路径断言会失败(404,body 非 HTML)。
/// GREEN 阶段:按工单 06 §五 5.1 的 Program.cs 顺序补齐中间件后,全部通过。
/// </summary>
public sealed class WebRootStaticFileTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _webRootDir;
private readonly HttpClient _client;
private readonly WebApplication _app;
/// <summary>
/// 写入到临时 wwwroot/index.html 的特征字符串,供断言用。
/// 避免与实际 Hua.Todo.Web 构建耦合(测试不需要真的 npm build)。
/// </summary>
private const string SpaMountPoint = "<div id=\"app\">SpaMount</div>";
private const string SpaTitle = "<title>Hua.Todo - ServedByHost</title>";
public WebRootStaticFileTests()
{
// === 将临时 wwwroot/index.html 写入默认 WebRoot(无需改 WebRoot,避免 WebApplicationBuilder.WebHost.UseWebRoot 抛 NotSupportedException)===
// WebApplicationBuilder 启动后默认 WebRoot = AppContext.BaseDirectory/wwwroot(即 bin/Debug/net10.0/wwwroot)
var baseDir = AppContext.BaseDirectory;
_webRootDir = Path.Combine(baseDir, "wwwroot");
Directory.CreateDirectory(_webRootDir);
File.WriteAllText(
Path.Combine(_webRootDir, "index.html"),
$"<!doctype html><html><head>{SpaTitle}</head><body>{SpaMountPoint}</body></html>");
// === 内存 SQLite(与既有集成测试同模式) ===
_connection = new SqliteConnection("Data Source=WebRootStaticFileTests;Mode=Memory;Cache=Shared");
_connection.Open();
_app = BuildHost(environmentName: "Development");
_client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
BaseAddress = new Uri(_app.Urls.First()),
};
}
private WebApplication BuildHost(string environmentName)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.EnvironmentName = environmentName;
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplicationServices("Data Source=WebRootStaticFileTests;Mode=Memory;Cache=Shared");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// === GREEN:按 Program.cs 硬约束顺序启用静态文件中间件 + SPA fallback ===
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
// SPA fallback(在 UseDynamicApi 之后,不覆盖 /api/*)
app.MapFallbackToFile("index.html");
app.StartAsync().GetAwaiter().GetResult();
return app;
}
[Fact]
public async Task Root_Get_ReturnsIndexHtml_WithAppMountDiv()
{
// Given: Host 启动且 WebRoot/wwwroot 有 index.html
// When : HTTP GET /
// Then : 200 + text/html + body 含 <div id="app"> 挂载点
using var response = await _client.GetAsync("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains(SpaMountPoint, body, StringComparison.Ordinal);
Assert.Contains(SpaTitle, body, StringComparison.Ordinal);
}
[Fact]
public async Task SpaRoute_Settings_Get_FallsBackToIndexHtml_Not404()
{
// Given: 同上
// When : HTTP GET /settings(前端内部路由)
// Then : 200 + body 是 index.html(注意:404 是 RED 时的现状,也是本测试在 RED 阶段失败的原因)
using var response = await _client.GetAsync("/settings");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains(SpaMountPoint, body, StringComparison.Ordinal);
}
[Fact]
public async Task ApiDynamicApiRoute_PostProbe_NotOverriddenByFallback()
{
// Given: 同上
// When : HTTP POST /api/cloud-sync/probe(匿名 DynamicApi 端点)
// Then : 返回 JSON(body 含 isReachable 字段),不是 index.html
// 不要求 isReachable=true,因为 test 里 Host 没监听 5173,必 false;也可能 503,但不是 HTML。
using var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
// 状态码不是 404 / 不返回 fallback 的 HTML:
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain(SpaMountPoint, body, StringComparison.Ordinal);
// 至少含 DynamicApi 的 JSON 特征:isReachable 字段
Assert.Contains("isReachable", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SwaggerUi_Get_Returns200_NotOverriddenByFallback()
{
// Given: 开发环境,Swagger 启用
// When : HTTP GET /swagger/index.html
// Then : 200,body 含 Swagger UI 的 "Swagger UI" 或 "swagger-ui",不是前端 index.html
using var response = await _client.GetAsync("/swagger/index.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("swagger", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(SpaMountPoint, body, StringComparison.Ordinal);
}
public void Dispose()
{
_client.Dispose();
try { _app.StopAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
try { _app.DisposeAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
_connection.Dispose();
// 清理临时写入的 index.html(只删我们自己写的,不删目录里的其他可能文件)
try
{
var idx = Path.Combine(_webRootDir, "index.html");
if (File.Exists(idx)) File.Delete(idx);
}
catch { /* 忽略临时文件清理失败 */ }
}
}
@@ -0,0 +1,255 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.DataSeeder;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests.DataSeederTests;
/// <summary>
/// <see cref="DataSeeder"/> 验收测试。
/// 对应研发工单 v1.2.0 工单 10 的验收标准(Given-When-Then),
/// 详见 docs/project/研发工单-v1.2.0/10-数据库初始化默认管理员.md 第五章。
/// </summary>
public sealed class DataSeederTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<TodoDbContext> _options;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
public DataSeederTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
_passwordHasher = new PasswordHasher<UserEntity>();
}
public void Dispose()
{
_connection.Dispose();
}
/// <summary>
/// 创建并迁移数据库上下文(每个测试独立实例,避免数据互相污染)。
/// </summary>
private TodoDbContext CreateContext()
{
var context = new TodoDbContext(_options);
context.Database.Migrate();
return context;
}
/// <summary>
/// 创建使用默认配置的 DataSeeder。
/// </summary>
private static DataSeeder CreateSeeder(TodoDbContext db, IPasswordHasher<UserEntity> hasher, AdminBootstrapOptions? options = null)
=> new(db, hasher, options ?? new AdminBootstrapOptions());
// ========== 5.1 主流程 ==========
/// <summary>
/// Given 全新空数据库(Migrate 完成,Users 表无任何云用户)
/// When 调用 IDataSeeder.SeedAsync()
/// Then Users 表新增一条记录,Role="admin",UserName 取配置值,MustChangePassword=true
/// And SecurityPolicies 表新增对应记录(AllowPersist=true, AllowSync=true)
/// And AuditLogs 表新增一条 EventType="SeedBootstrap" 记录
/// </summary>
[Fact]
public async Task SeedAsync_EmptyDatabase_CreatesDefaultAdminWithPolicyAndAuditLog()
{
using var db = CreateContext();
var options = new AdminBootstrapOptions { UserName = "admin", Password = "Admin@123", Enabled = true };
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var admin = await db.Users.SingleOrDefaultAsync(u => u.Role == "admin");
Assert.NotNull(admin);
Assert.Equal("admin", admin!.UserName);
Assert.True(admin.MustChangePassword);
var policy = await db.SecurityPolicies.SingleOrDefaultAsync(p => p.UserId == admin.Id);
Assert.NotNull(policy);
Assert.True(policy!.AllowPersist);
Assert.True(policy.AllowSync);
var audit = await db.AuditLogs.SingleOrDefaultAsync(a => a.EventType == "SeedBootstrap");
Assert.NotNull(audit);
Assert.True(audit!.IsSuccess);
}
// ========== 5.2 幂等性 ==========
/// <summary>
/// Given 数据库已存在 Role="admin" 的云用户
/// When 调用 SeedAsync()
/// Then Users 表不新增记录
/// And AuditLogs 表不新增 SeedBootstrap 记录
/// </summary>
[Fact]
public async Task SeedAsync_ExistingCloudUser_DoesNotCreateDuplicate()
{
using var db = CreateContext();
var existingAdmin = await SeedExistingAdminAsync(db, "admin");
var seeder = CreateSeeder(db, _passwordHasher);
await seeder.SeedAsync();
var admins = await db.Users.Where(u => u.Role == "admin").ToListAsync();
Assert.Single(admins);
Assert.Equal(existingAdmin.Id, admins[0].Id);
var seedAudits = await db.AuditLogs.Where(a => a.EventType == "SeedBootstrap").ToListAsync();
Assert.Empty(seedAudits);
}
// ========== 5.3 配置自定义账号 ==========
/// <summary>
/// Given AdminBootstrapOptions.UserName="root" 且 Password="MyStrong#123"
/// When 调用 SeedAsync()
/// Then 创建的管理员 UserName="root"
/// And 使用该密码可通过 IPasswordHasher.VerifyHashedPassword 校验
/// </summary>
[Fact]
public async Task SeedAsync_CustomOptions_UsesConfiguredCredentials()
{
using var db = CreateContext();
var options = new AdminBootstrapOptions
{
Enabled = true,
UserName = "root",
Password = "MyStrong#123"
};
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var admin = await db.Users.SingleOrDefaultAsync(u => u.UserName == "root");
Assert.NotNull(admin);
Assert.Equal("admin", admin!.Role);
var verify = _passwordHasher.VerifyHashedPassword(admin, admin.PasswordHash, "MyStrong#123");
Assert.Equal(PasswordVerificationResult.Success, verify);
}
// ========== 5.4 配置禁用 ==========
/// <summary>
/// Given AdminBootstrapOptions.Enabled=false
/// When 调用 SeedAsync()
/// Then 不执行任何种子逻辑,直接返回
/// </summary>
[Fact]
public async Task SeedAsync_DisabledOption_DoesNothing()
{
using var db = CreateContext();
var options = new AdminBootstrapOptions { Enabled = false };
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var userCount = await db.Users.CountAsync();
Assert.Equal(0, userCount);
var auditCount = await db.AuditLogs.CountAsync();
Assert.Equal(0, auditCount);
}
// ========== 5.5 密码哈希安全 ==========
/// <summary>
/// Given 执行 SeedAsync() 成功
/// When 查询 Users 表
/// Then PasswordHash 不等于明文密码
/// And PasswordHash 可通过 IPasswordHasher.VerifyHashedPassword(明文) 校验为 Success
/// </summary>
[Fact]
public async Task SeedAsync_PasswordHashed_NotStoredInPlaintext()
{
using var db = CreateContext();
const string plainPassword = "Admin@123";
var options = new AdminBootstrapOptions { Enabled = true, UserName = "admin", Password = plainPassword };
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var admin = await db.Users.SingleAsync(u => u.Role == "admin");
Assert.NotEqual(plainPassword, admin.PasswordHash);
Assert.NotEmpty(admin.PasswordHash);
var verify = _passwordHasher.VerifyHashedPassword(admin, admin.PasswordHash, plainPassword);
Assert.Equal(PasswordVerificationResult.Success, verify);
}
// ========== 5.6 既有 local 用户不影响初始化 ==========
/// <summary>
/// Given Users 表仅存在 Role="local" 的本地用户(嵌入式遗留数据)
/// When 调用 SeedAsync()
/// Then 仍然创建默认管理员(因为幂等条件是 Role != "local" 的云用户)
/// </summary>
[Fact]
public async Task SeedAsync_OnlyLocalUserExists_StillCreatesAdmin()
{
using var db = CreateContext();
await SeedLocalUserAsync(db, "local");
var seeder = CreateSeeder(db, _passwordHasher);
await seeder.SeedAsync();
var localUser = await db.Users.SingleAsync(u => u.Role == "local");
var admin = await db.Users.SingleOrDefaultAsync(u => u.Role == "admin");
Assert.NotNull(admin);
Assert.NotEqual(localUser.Id, admin!.Id);
Assert.Equal(2, await db.Users.CountAsync());
}
// ========== 辅助方法 ==========
/// <summary>
/// 写入一个已存在的云管理员(模拟"系统已初始化"场景)。
/// </summary>
private async Task<UserEntity> SeedExistingAdminAsync(TodoDbContext db, string userName)
{
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = userName,
Role = "admin",
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow
};
user.PasswordHash = _passwordHasher.HashPassword(user, "Existing#123");
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
/// <summary>
/// 写入一个本地用户(Role="local",嵌入式模式遗留数据)。
/// </summary>
private async Task SeedLocalUserAsync(TodoDbContext db, string userName)
{
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = userName,
Role = "local",
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow
};
user.PasswordHash = _passwordHasher.HashPassword(user, "local-dummy");
db.Users.Add(user);
await db.SaveChangesAsync();
}
}