diff --git a/docs/superpowers/specs/2026-06-11-apiclient-async-design.md b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md new file mode 100644 index 0000000..844ddc7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-apiclient-async-design.md @@ -0,0 +1,248 @@ +# 设计:ApiClient 异步化(DatasetDetail 路径试点) + +> 日期 2026-06-11。范围:把数据集详情路径从同步阻塞改为异步非阻塞,作为全 App 异步化的模式样板。 +> 后续会按此模式铺开到导航/登录路径(本期不做)。 + +## 1. 背景 + +geopro 现网络栈三层全同步阻塞: + +- **`ApiClient`**(`src/net/`):单个 `QNetworkAccessManager`,`get/postJson` 内部用 `QNetworkReply + QEventLoop` 死等响应。共享 cookie / JSESSIONID。 +- **Repository**(`ApiDatasetRepository`、`ApiProjectRepository`):同步调 `api_.get/postJson`,解析后返回 typed 值或抛 `std::runtime_error`。 +- **Controller**(`DatasetDetailController`、`WorkbenchNavController`):在 slot 内同步调 repo,再 `emit` 结果信号。 + +**核心问题**:每次请求都用 `QEventLoop` 阻塞 UI 线程 → 全 App 冻结。其中数据集详情「网格数据」页签的 `dd/ert/inversion/rows/{id}` 请求因**服务端网格化波动 1–4s**,期间界面完全卡死。 + +`QEventLoop` 阻塞的副作用是**可重入**:阻塞期间 Qt 仍泵事件,用户的双击/切换会重入 slot,因此现有控制器到处用 `busy_` 守卫挡重入——这些守卫本身就是同步阻塞架构的产物。 + +`QNetworkAccessManager` 本就是异步设计;当前架构用 `QEventLoop` 把它人为变同步,是反模式。本设计回归 Qt 原生异步。 + +## 2. 目标与非目标 + +### 目标 + +1. 数据集详情路径(原数据 + 网格数据)全程**不阻塞 UI 线程**。 +2. 慢请求在飞时切换/关闭数据集页签可**主动 abort 过期请求**,结果不回灌。 +3. 同一逻辑加载内的多个独立请求**并发**发出(原数据 2 个、网格 3 个),缩短总等待。 +4. 加载期间给用户**轻量"加载中"反馈**(不再因冻结而"看起来在响应")。 +5. 产出**可复用的异步原语**(`ApiCall` / `ApiBatch` / 句柄模式),供后续导航/登录路径照搬。 +6. 现有测试不退化;新增异步路径的单测可离线运行。 + +### 非目标(本期不做) + +- **不**改 `WorkbenchNavController` / `ApiProjectRepository` / `AuthService` / 登录流程——它们继续走同步 `ApiClient.get/postJson`。 +- **不**做后台工作线程:网络是 I/O 密集,`QNetworkAccessManager` 原生异步即可,不引入 `QThread`/`QtConcurrent`。 +- **不**追求与原版 loading 动画像素级一致(轻量提示即可,细抠后续单独做)。 +- **不**改 `LocalSampleRepository`(它实现旧同步 `IDatasetRepository`,但不经详情控制器使用)。 + +## 3. 已定决策(及理由) + +| 决策 | 选择 | 理由 | +|---|---|---| +| 范围 | 仅 DatasetDetail 路径 | 风险小、最痛点(1–4s rows)先解决、产出可复用模式 | +| 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 | +| 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 | +| 异步机制 | 方案 A:每请求一个 QObject 句柄(信号 + abort) | 纯 Qt 信号、贴合现有信号驱动代码库;abort 用具体句柄最干净(QFuture 的 cancel 不中断底层 reply,反而要再加句柄);测试用 QSignalSpy 顺手 | +| 多请求汇聚 | gather-all(等全部完成再回报,校验留 repo) | 避免 fail-fast 的部分-abort 竞态;与现有"任一失败→整体失败"语义对齐(校验在 repo 逐项判定) | + +## 4. 架构 + +``` +DatasetDetailController (QObject,状态/编排) + │ 调 repo.loadChartAsync(id) / loadGridAsync(id),持有返回的 *Load 句柄 + │ 切换/关页 → 句柄.abort() + ▼ +IAsyncDatasetRepository(纯抽象) ←── ApiDatasetRepository 实现 + │ 每次 loadXxxAsync 创建一个 *Load 句柄,内部用 ApiBatch 并发 N 个 ApiCall + │ batch 全部完成 → 逐项校验+解析 → emit done(Parts) / failed(msg) + ▼ +ApiClient.getAsync(path) → ApiCall(包一个 QNetworkReply,实现 IApiCall) + (ApiClient 保留同步 get/postJson 供 Nav/登录继续使用,共享同一 QNetworkAccessManager) +``` + +**信号面保持不变**:`DatasetDetailController` 对外仍发 `chartReady(ChartData)` / `gridReady(GridData)` / `loadFailed(dsId,msg)` / `focusRequested(dsId)`,新增 `loadStarted(dsId, Phase)`。因此 `main.cpp` 接线与 `DatasetDetailPanel` 几乎不动,仅接 `loadStarted` 显示遮罩。 + +## 5. 组件设计 + +### 5.1 net 层 + +#### `IApiCall`(新增,抽象,可测试缝) +```cpp +// src/net/IApiCall.hpp +class IApiCall : public QObject { + Q_OBJECT +public: + using QObject::QObject; + virtual void abort() = 0; // 中断;不再 emit finished;deleteLater +signals: + void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError) +}; +``` +存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call(不碰真实网络),离线测 gather/abort。 + +#### `ApiCall`(新增,实现 `IApiCall`) +- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished` → `deleteLater()` 自删。 +- `abort()`:断开自身与 reply 的连接、`reply->abort()`、`reply->deleteLater()`、`this->deleteLater()`;**保证 abort 后不再 emit `finished`**(杜绝旧结果回灌)。 +- 一处微妙:abort 与 reply 即将 finished 的竞态——先 `disconnect` 再 `abort`,确保 finished 不触达本对象的 emit。 + +#### `ApiClient`(扩展,不破坏现有) +```cpp +net::IApiCall* getAsync(const QString& path); // 立即返回,不阻塞 +net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); +// 保留:ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用 +``` +- `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。 +- **已知交互(记录为可接受)**:Nav 的同步嵌套 `QEventLoop` 期间,详情的异步 reply 若完成会被一并泵出、其槽重入执行——无害(详情切换会 abort,且互不写同一状态)。全异步后该同步路径消失。 + +#### `ApiBatch`(新增,汇聚原语) +```cpp +// src/net/ApiBatch.hpp +class ApiBatch : public QObject { + Q_OBJECT +public: + explicit ApiBatch(QList calls, QObject* parent=nullptr); // 接管 calls + void abort(); // abort 全部子 call,self deleteLater,不 emit +signals: + void finished(const QList& responses); // 按 calls 下标对齐 +}; +``` +- gather-all:记录每个 call 的 `finished` 到 `responses_[i]`,计数到齐后 emit `finished` 并 `deleteLater`。 +- `abort()`:对每个未完成子 call 调 `abort()`,自删,不 emit。 +- 不做成功/失败判定(留 repo):传输/HTTP 错误体现在对应 `ApiResponse.rawError`/`httpStatus`。 + +### 5.2 data 层 + +#### data 层结果载体(中性,避免 data→controller 反向依赖) +```cpp +// src/data/api/DatasetLoads.hpp +struct ChartParts { core::ScatterField scatter; core::ColorScale scatterScale; }; +struct GridParts { core::Grid grid{1,1}; core::ColorScale gridScale; std::vector anomalies; }; +``` + +#### `*Load` 句柄(每次加载一个,承载 typed 结果 + abort) +```cpp +class ChartLoad : public QObject { + Q_OBJECT +public: + void abort(); // 转发给内部 ApiBatch +signals: + void done(const geopro::data::ChartParts& parts); + void failed(const QString& message); +}; +class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ }; +``` +- 由 repo 创建并持有内部 `ApiBatch`;batch 完成时逐项校验+解析→ emit `done`/`failed`→ self `deleteLater`。 +- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。 + +#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它) +```cpp +// src/data/repo/IAsyncDatasetRepository.hpp +class IAsyncDatasetRepository { +public: + virtual ~IAsyncDatasetRepository() = default; + virtual data::ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + scatterScale(type1) + virtual data::GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + scale(type2) + anomalies +}; +``` +- 复合方法(而非旧 6 个原子 `loadXxx`):原子方法把"该发哪几个请求"泄漏给了控制器;汇聚+abort 是核心复杂度,集中放 repo(它本就知道请求集)是正确归属,控制器保持轻薄。 + +#### `ApiDatasetRepository`(改造) +- 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`。 +- `loadChartAsync`:`ApiBatch{ getScatter, getScatterScale }` → 完成后复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts`;任一项错误 → `failed`。 +- `loadGridAsync`:`ApiBatch{ getRows, getColorScale, getException }` → 解析填 `GridParts`;同上。 +- 现有同步解析/校验逻辑(DTO、v 行数校验、markType 钳制)原样搬入异步完成回调,不重写。 + +### 5.3 controller 层 + +#### `DatasetDetailController`(改造) +```cpp +enum class Phase { Chart, Grid }; +signals: + void loadStarted(const QString& dsId, Phase phase); // 新增:驱动加载遮罩 + // 不变:chartReady(ChartData)/gridReady(GridData)/loadFailed(dsId,msg)/focusRequested(dsId) +private: + IAsyncDatasetRepository& repo_; + QPointer chartLoad_; // 当前在飞(QPointer 防悬垂) + QPointer gridLoad_; +``` +- `openDataset(dsId, ddCode)`:ddCode 非 `dd_inversion_data` → `loadFailed`;否则 **若 `chartLoad_` 在飞先 abort**,`repo_.loadChartAsync` 取新句柄,连 `done`→组 `ChartData`→`chartReady`、`failed`→`loadFailed`,emit `loadStarted(dsId, Chart)`。 +- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_` 与 `gridReady`。 +- **移除 `busy_` 守卫**:由 abort-and-replace 取代(新请求来→abort 旧→发新)。重入不再是危险(无嵌套事件循环)。 +- 句柄完成/失败后清空对应 `QPointer`。控制器析构时 abort 在飞句柄。 + +### 5.4 UI 层(加载反馈) + +- 新增 `LoadingOverlay`(`QWidget`,半透明 + "加载中…" + 可选 busy 指示),可贴在任一视图区上层。 +- `DatasetDetailPanel`: + - 接 `loadStarted(dsId, Grid)` → 在对应 ds 页的网格视图上显示遮罩 + 禁用交互;`gridReady`/`loadFailed` → 隐藏。 + - 原数据初次加载(~0.8s):页面仍于 `chartReady` 创建;加载期用 busy 光标 + 状态栏"加载中…"(最小改动)。网格遮罩是高价值项(页已存在、等待 1–4s)。 +- 像素级对齐原版 loading 留后续。 + +## 6. 数据流(时序) + +**原数据(双击数据集)** +1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`(UI 置 busy)。 +2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`。 +3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发;UI 线程**保持响应**。 +4. 两 reply 到齐 → batch.finished → repo 解析 → `ChartLoad::done(ChartParts)`。 +5. controller 组 `ChartData` → `chartReady` → `detailPanel->openOrUpdate`(清 busy)。 + - 任一错误 → `ChartLoad::failed` → `loadFailed` → 状态栏提示(清 busy)。 + +**网格数据(首次切到网格页签)** +1. `gridDataNeeded` → `loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。 +2. abort 旧 `gridLoad_` → `repo.loadGridAsync(id)`。 +3. `ApiBatch{getRows(慢1–4s), getColorScale, getException}` 并发。 +4. 到齐 → 解析 → `done(GridParts)` → `gridReady` → `setGridData`(隐遮罩)。 +5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort,无回灌。 + +## 7. 错误处理与边界 + +- **传输/HTTP 错误**:体现在 `ApiResponse.rawError`/`httpStatus`;repo 在解析处判定并 `failed(msg)`,控制器转 `loadFailed` → 状态栏(沿用现有 UX)。 +- **abort 竞态**:`ApiCall::abort` 先 `disconnect` 再 `abort`,确保 abort 后不 emit。`*Load` 与 `ApiBatch` abort 后 `deleteLater` 且不 emit。控制器用 `QPointer` 防悬垂。 +- **重复触发**:abort-and-replace 天然幂等(新覆旧)。 +- **控制器析构**:abort 所有在飞句柄,避免回调打到已析构对象。 +- **共享 NAM 同步/异步并存**:见 5.1,记录为可接受,全异步后消除。 +- **空/缺数据**:异常列表可能为空——repo 判定"业务成功但无数据"应走 `done`(空),非 `failed`,与现状一致。 + +## 8. 测试策略 + +- **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。 + - gather-all:全部 finished 后 emit 一次、下标对齐。 + - abort:未完成子 call 均被 abort、不 emit。 + - 含错误响应仍 gather-all(不短路)。 +- **`DatasetDetailController`(离线单测,QSignalSpy)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod` 排队)。 + - `done` → 一次 `chartReady`/`gridReady`。 + - `failed` → 一次 `loadFailed`。 + - 在飞时再 `openDataset` → 旧句柄被 `abort`(stub 记录)。 + - 非 `dd_inversion_data` → 直接 `loadFailed`。 +- **`ApiCall`/`getAsync`(可选 live test)**:依现有 `AuthLiveTest` 先例,标记为 live,不计入离线覆盖门槛。 +- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本;其余不动。`LocalRepo.*` 不受影响(同步接口保留)。 +- 目标:异步新增逻辑(batch/controller)离线单测覆盖 ≥ 80%。 + +## 9. 迁移步骤(供 writing-plans 细化) + +1. net:`IApiCall` + `ApiCall` + `ApiClient::getAsync/postJsonAsync`(保留同步)。`parseBody` 抽为可复用。 +2. net:`ApiBatch` + 离线单测。 +3. data:`DatasetLoads.hpp`(ChartParts/GridParts)+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`。 +4. data:`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。 +5. controller:`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。 +6. UI:`LoadingOverlay` + `DatasetDetailPanel` 接 `loadStarted`;`main.cpp` 装配换 `IAsyncDatasetRepository`。 +7. 全量构建 + 测试(dev-build / dev-test);手动验证:切换/关页 abort、网格遮罩、并发加载、UI 不冻。 + +## 10. 风险 + +| 风险 | 缓解 | +|---|---| +| abort 后回调悬垂/二次释放 | `QPointer` + abort 先 disconnect + 仅 `deleteLater` | +| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater`;父子 QObject 归属清晰 | +| 同步/异步共用 NAM 重入 | 记录为可接受;详情 abort 隔离;全异步后消除 | +| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;保留现有解析单测 | +| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口;`LocalSampleRepository` 不碰 | + +## 11. 相关文件 + +- 改:`src/net/ApiClient.{hpp,cpp}`、`src/data/api/ApiDatasetRepository.{hpp,cpp}`、`src/controller/DatasetDetailController.{hpp,cpp}`、`src/app/main.cpp`、`src/app/panels/.../DatasetDetailPanel.*`、`tests/controller/test_dataset_detail_controller.cpp`、各 `CMakeLists.txt`。 +- 新:`src/net/IApiCall.hpp`、`src/net/ApiCall.{hpp,cpp}`、`src/net/ApiBatch.{hpp,cpp}`、`src/data/api/DatasetLoads.hpp`、`src/data/api/DatasetLoadHandles.{hpp,cpp}`(ChartLoad/GridLoad)、`src/data/repo/IAsyncDatasetRepository.hpp`、`src/app/.../LoadingOverlay.{hpp,cpp}`、`tests/net/test_api_batch.cpp`。 +- 不动:`src/data/repo/IDatasetRepository.hpp`(同步,留给 LocalSampleRepository)、Nav/Auth/Project 路径。 + +