1184 lines
74 KiB
Markdown
1184 lines
74 KiB
Markdown
# ApiClient 异步化 — 全 App 铺开(导航 + 登录)实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务执行本计划。每个 Task 内的 Step 用复选框(`- [ ]`)跟踪。每个 bite-sized Step 是一个 2–5 分钟动作(写失败测试 → 跑 → 实现 → 跑 → 提交)。
|
||
|
||
**Goal:** 把数据集详情试点已验证的「异步句柄 + abort 闸门」模式,铺开到全 App 剩余两条同步阻塞路径——**导航**(`ApiProjectRepository` + `WorkbenchNavController`,9 个仓储方法)与**登录**(`AuthService` 串行依赖链 + `LoginWindow`)。完成后 `ApiClient` 全程不再用 `QEventLoop` 阻塞 UI 线程,可移除同步 `get/postJson`。
|
||
|
||
**Architecture:** 复用 net 层已落地原语 `IApiCall`/`ApiCall`/`ApiBatch`(不重造)。新增一个**顺序执行原语 `ApiChain`**(依赖链:上一步结果喂下一步 + abort + aborted_ 闸门),供登录与导航的依赖链共用。data 层导航仓储改异步:用**统一泛型句柄 `NavRequest<T>`**(抽象基 `INavRequest<T>` + `ApiNavRequest<T>` 实现,控制类爆炸),仓储方法返回 `NavRequest<T>*`(emit `done(T)`/`failed(QString)`),替代同步 `RepoResult<T>`。controller 层 `WorkbenchNavController` 用 abort-and-replace + 句柄身份比对取代 `busy_` 守卫与 `drainPendingCheckedTms` 重放机制(异步后 QEventLoop 重入消失,重放逻辑自然消亡)。登录 `LoginWindow` 接 `AuthService` 异步信号,登录期不冻、可取消。安全靠各层 `aborted_` 入口守卫 + 句柄身份比对 + 一律 `deleteLater`(沿用 spec §5.0)。
|
||
|
||
**Tech Stack:** Qt6(Core/Network/Test)、QNetworkAccessManager 原生异步、CMake + Ninja + MSVC、GoogleTest/CTest、QSignalSpy、`tests/net/FakeApiCall.hpp`(复用)。
|
||
|
||
**权威设计:** `docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(§5.0 安全不变量、§7 错误判定 code==200/退出契约)。试点实现计划范式:`docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`。
|
||
|
||
**构建/测试命令:**
|
||
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
- 若报 `LNK1104 ... geopro_desktop.exe`:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。
|
||
- 全量测试(**只跑 ctest,不构建——须先 dev-build**):`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiChain.*`
|
||
- **基线:当前 89/89 绿。** 本计划第一阶段(导航)完成后预期 89 → ~104;第二阶段(登录)完成后预期 ~108。
|
||
|
||
---
|
||
|
||
## 阶段划分与拆分边界(顶部决策)
|
||
|
||
本计划范围大(导航 9 方法 + 控制器复杂状态机 + 登录串行链 + 一个新原语),**建议分两阶段,并可拆成两份独立 plan 分两个 PR 落地**:
|
||
|
||
- **Part A — 导航(本文件给出全部 bite-sized 任务,Task A0–A6)。** 先行理由:导航虽方法多但每个方法是「值进 → 句柄出」的机械翻译,模式与详情试点最贴近,风险**可控且可分散提交**;其控制器 `busy_`/drain 重构是难点但有详情控制器先例可照搬。`ApiChain` 原语在 Part A 内落地(导航依赖链需要它),同时为登录铺路。
|
||
- **Part B — 登录(本文件给出高层任务骨架,Task B0–B5)。** 后行理由:登录是**串行依赖链 + 共享会话 + 模态对话框 `exec()`**,且有联网 live 测试 `test_auth.cpp` 需改造,风险点集中(会话 cookie、模态循环里的异步、取消语义),适合在 `ApiChain` 经导航验证稳定后再做。Part B 详细 bite-sized 任务在落地 Part A 后,按本骨架另起一份 plan(`2026-06-11-apiclient-async-login.md`)细化——或直接在本文件继续展开。
|
||
|
||
> **拆 plan 的判断:** 推荐拆。Part A 一个 PR(导航全异步,含 `ApiChain`),Part B 一个 PR(登录全异步 + 移除同步 `get/postJson`)。两 PR 之间 `ApiClient` 同步方法保留供登录过渡(登录是最后一个同步消费者)。若评审倾向单 PR,则按 Task 顺序 A0→A6→B0→B5 连续执行,每 Task 仍独立提交。
|
||
|
||
---
|
||
|
||
## 关键设计决策
|
||
|
||
### 决策 1:登录串行依赖链 → 新增 `ApiChain` 原语(而非嵌套 ApiCall 续延)
|
||
|
||
**问题:** 登录是 `verifyCodeCheck` → RSA(本地) → `login2`,每步用上一步结果(且都走同一 NAM 共享 JSESSIONID)。导航也有依赖链:`start` = listWorkspaces → pageProjects → loadStructure;`switchWorkspace` = switch(写新 token) → pageProjects → loadStructure。`ApiBatch` 是**并发汇聚**,不适配。
|
||
|
||
**候选:**
|
||
- (a) 嵌套 `ApiCall` 续延(在每个 `finished` lambda 里发下一个请求):能用,但 abort/aborted_ 闸门要在每层手写,易漏;多处复制;测试难复用。
|
||
- (b) **`ApiChain` 顺序执行原语(推荐)**:一个 QObject,持有一个「步骤列表」,每步是 `std::function<IApiCall*(const QList<ApiResponse>& prior)>`(用既往响应构造下一请求,含本地变换如 RSA)。逐步执行:每步 `finished` → `isFailure` 判定(失败则 fail + abort + deleteLater)→ 成功则记录响应、执行下一步;末步成功 → `succeeded(QList<ApiResponse>)`。`abort()` 置 `aborted_`、abort 当前在飞 call、deleteLater。**入口守卫 `if (aborted_) return;` 同 ApiBatch。**
|
||
|
||
**推荐 (b)。** 与 `ApiBatch` 对称(同 `Predicate`、同信号面 `succeeded(QList<ApiResponse>)`/`failed(int,ApiResponse)`、同安全不变量),可离线单测(FakeApiCall 注入),登录与导航依赖链共用。RSA 这类**纯本地步骤**不发请求:用「步骤工厂返回 nullptr 表示纯本地变换已在工厂内完成,直接进下一步」过于隐晦——改为 RSA 在「构造 login2 请求的工厂 lambda」内同步完成(工厂可抛 `std::exception`,被 ApiChain 捕获转 `failed`),无需把本地步骤建模为独立 chain step。
|
||
|
||
**共享会话不变量:** `ApiChain` 的每个 step 工厂调 `api_.postJsonAsync/getAsync`,全部走同一 `ApiClient`(同一 NAM),cookie/JSESSIONID 自然串联——与现同步链一致(见 `ApiClient.hpp:24-27` 注释)。`switchWorkspace` 链中途 `api_.setToken(newToken)` 的副作用:必须在「switch 响应到达、构造下一请求之前」执行——`ApiChain` 的 step 工厂在「构造下一请求时」运行,故把 `setToken` 放进「pageProjects 请求工厂」的开头(它能读到 switch 的响应),时序正确。
|
||
|
||
### 决策 2:导航 9 方法分类(依赖链 / 单请求 / 可并发)
|
||
|
||
| 控制器入口 | 仓储调用序列 | 类型 | 原语 |
|
||
|---|---|---|---|
|
||
| `start()` | listWorkspaces → pageProjects → loadStructure | 依赖链 | `ApiChain` |
|
||
| `switchWorkspace(id)` | switchWorkspace(setToken) → pageProjects → loadStructure | 依赖链 | `ApiChain` |
|
||
| `switchProject(id)` | loadStructure | 单请求 | `NavRequest<T>`(单 ApiCall) |
|
||
| `selectObject(id,t)` | loadRows(data) + loadRows(file) + loadObjectDetail | **可并发** | `ApiBatch` |
|
||
| `loadMoreData()` | loadRows(data) | 单请求 | `NavRequest<T>` |
|
||
| `loadMoreFiles()` | loadRows(file) | 单请求 | `NavRequest<T>` |
|
||
| `selectDataset(id)` | loadDatasetForm | 单请求 | `NavRequest<T>` |
|
||
| `setCheckedTms(ids)` | loadExceptionsByTm × N(带缓存,缺失才拉) | **可并发**(仅未命中缓存项) | `ApiBatch` |
|
||
|
||
> 仓储层保持「一方法一请求」的薄封装(返回 `NavRequest<T>*`),**汇聚/链式编排放控制器**(它知道完整序列与状态)——与详情试点把汇聚放 repo 不同,因为导航的序列依赖控制器状态(缓存、当前项目/父节点),放 repo 会泄漏状态。这是导航与详情的**有意差异**,在 self-review 中复核。
|
||
|
||
### 决策 3:`RepoResult<T>` 异步化 → 统一泛型句柄 `NavRequest<T>`
|
||
|
||
**问题:** 同步 `RepoResult<T>` 返回值需变成「句柄 emit done(T)/failed(msg)」。导航有 9 个方法、7 种返回类型(`vector<Workspace>`、`bool`、`ProjectListPage`、`vector<ProjectType>`、`vector<StructNode>`、`DsPage`、`DynamicForm`、`vector<ExceptionRow>`)。每类型一个具体句柄类 = 类爆炸(且各自 Q_OBJECT/moc)。
|
||
|
||
**候选:**
|
||
- (a) 每方法/每类型一句柄(如详情的 `ChartLoad`/`GridLoad`):详情只有 2 个,可接受;导航 7+ 个,爆炸。
|
||
- (b) **模板句柄 `NavRequest<T>`(推荐)**:抽象基 `INavRequest<T>`(纯虚 `abort()` + signals `done(T)`/`failed(QString)`)+ `ApiNavRequest<T>`(包一个 `IApiCall`,构造注入 `std::function<T(const ApiResponse&)>` 解析器)。**Qt 限制:模板类不能含 `Q_OBJECT`(moc 不支持模板)。** 解决:基类用**非模板** `NavRequestBase : QObject`(Q_OBJECT,signals 用 `QVariant` 承载 payload),模板层 `NavRequest<T>` 继承它、提供 typed `done(T)` 转发;或更简单——**信号 payload 用 `QVariant`,typed 在控制器侧 `value.value<T>()` 取出**。但 `T` 含自定义结构(`DsPage` 等)需 `Q_DECLARE_METATYPE` + 同线程直连无需注册。
|
||
- (c) 统一非模板 `NavRequest`,payload 一律 `QVariant`(装任意 `T`)+ 控制器取出:**最省类**,无模板/moc 难题,代价是控制器侧 `.value<DsPage>()` 取值(需对各 `T` 加 `Q_DECLARE_METATYPE`)。
|
||
|
||
**推荐 (c) 的变体:单个非模板 `NavRequest : QObject`,signals `done(const QVariant&)`/`failed(const QString&)`,`abort()`;具体 `ApiNavRequest` 包 `IApiCall` + `std::function<QVariant(const ApiResponse&)>` 解析器。** 理由:① 零模板/零 moc 难题(单类,单次 Q_OBJECT);② 控制器已是「拿到 typed → emit 既有 typed 信号」,多一步 `qvariant_cast<DsPage>` 成本极小;③ 解析器 lambda 在 repo 内捕获 DTO 解析(与现 `dto::parseXxx` 一对一);④ abort/aborted_ 闸门只在一处实现。对各返回类型加 `Q_DECLARE_METATYPE`(`DsPage`/`DynamicForm`/`ProjectListPage`/`vector<...>` 等,同线程直连下声明即可、无需 `qRegisterMetaType`)。
|
||
|
||
> 与详情试点的 `ChartLoad`/`GridLoad`(typed 信号)风格略不同,但详情只 2 类、且 Parts 是合成结构;导航 7 类用 typed-per-class 不划算。`NavRequest`(QVariant) 是导航规模下的正确权衡,在 self-review 复核类型一致性。
|
||
|
||
### 决策 4:`WorkbenchNavController` 的 `busy_` / `drainPendingCheckedTms` 演化(难点)
|
||
|
||
**现状根因:** `busy_` 守卫 + `BusyGuard` RAII 配平 `busyChanged` + `drainPendingCheckedTms` 重放,全部是**同步 `QEventLoop` 阻塞下「Qt 仍泵事件 → slot 重入」**的产物(spec §1)。`setCheckedTms` 在 `busy_` 时把请求挂起、待 `BusyGuard` 析构经 `QueuedConnection` 重放,正是为对抗嵌套循环重入。
|
||
|
||
**异步后演化:**
|
||
1. **删除 `busy_` 守卫**:异步不阻塞、无嵌套循环、无 slot 重入。
|
||
2. **删除 `checkedTmsPending_` / `pendingCheckedTms_` / `drainPendingCheckedTms` / `BusyGuard` / `friend struct BusyGuard`**:重放机制随重入消失而消亡。
|
||
3. **保留 `busyChanged(bool)` 信号**(main.cpp:751 接等待光标):语义从「同步阻塞中」改为「有在飞请求」。实现:控制器持有「在飞句柄计数 / 或每类一个 QPointer」,**发起请求时若之前无在飞→emit busyChanged(true),最后一个完成/失败→emit busyChanged(false)**。最简实现:每条路径一个 QPointer 成员(`startChain_`/`structReq_`/`selectBatch_`/`moreDataReq_`/`moreFilesReq_`/`datasetReq_`/`exceptionsBatch_`),任一非空即 busy。**KISS:** 用一个辅助 `emitBusyIfChanged()` 在每次 start/clear 后根据「是否存在任一在飞句柄」发信号(去抖:值变才发)。
|
||
4. **abort-and-replace + 句柄身份比对**(取代 busy 拒绝重入):每条路径 abort 旧句柄、存新句柄、`done`/`failed` 里比对身份(`load != xxxReq_` 则丢弃迟到信号),与详情控制器一一对应。
|
||
5. **`setCheckedTms` 的重入语义**:原「busy 时挂起最新一次、空闲重放」→ 异步后「**abort 旧异常批、发新批**」。用户快速改勾选 = 新请求覆盖旧请求(abort 旧 batch + 身份比对丢旧结果),**自然实现「以最后一次勾选为准」**,比原挂起-重放更简单且语义更优。缓存 `tmExceptionCache_` 保留(命中不发请求)。
|
||
|
||
> **难点处理明确化(无 TBD):** `drainPendingCheckedTms` 删除,其「最后一次为准」语义由 abort-and-replace 承接;`busy_` 删除,`busyChanged` 由「在飞句柄存在性」驱动;`BusyGuard`/`friend` 删除。验证用例见 Task A6 的 `SetCheckedTmsAbortsPreviousBatch` / `BusyChangedReflectsInflight`。
|
||
|
||
### 决策 5:原子落地以保持构建绿(迁移顺序)
|
||
|
||
接口改形强耦合控制器(详情试点 Task5+6 合并的教训):`IProjectRepository`(同步 `RepoResult`)→ `IAsyncProjectRepository`(句柄)是**破坏性改形**,`ApiProjectRepository` 与 `WorkbenchNavController` 必须**同批提交**。本计划在 Task A4(repo 改形)与 Task A5(controller 改形)之间标注「**A4+A5 必须同一提交或连续提交且中间不要求构建绿**」——推荐做法:先在 A3 引入 `IAsyncProjectRepository` 新接口与 `NavRequest`(**新增、不删旧**,构建仍绿、旧路径仍用同步),A4 让 `ApiProjectRepository` **同时实现新旧两接口**(过渡),A5 切换控制器到新接口,A6 删除旧同步接口/方法。这样每步构建绿、可独立提交。
|
||
|
||
---
|
||
|
||
## 文件结构(每文件职责)
|
||
|
||
### Part A — 导航
|
||
|
||
**新建:**
|
||
- `src/net/ApiChain.hpp` / `src/net/ApiChain.cpp` — 顺序执行原语。持步骤工厂列表 `QList<StepFactory>`(`std::function<IApiCall*(const QList<ApiResponse>& prior)>`,工厂可抛 `std::exception`)+ `Predicate isFailure`;逐步执行、fail-fast、`aborted_` 闸门、一律 deleteLater。signals `succeeded(QList<ApiResponse>)` / `failed(int index, ApiResponse)`。
|
||
- `tests/net/test_api_chain.cpp` — ApiChain 离线单测(FakeApiCall 注入;顺序、失败短路、abort 闸门、工厂抛异常转 failed)。
|
||
- `src/data/api/NavRequest.hpp` / `src/data/api/NavRequest.cpp` — `NavRequest : QObject`(抽象基:纯虚 `abort()`,signals `done(QVariant)`/`failed(QString)`)+ `ApiNavRequest`(包 `IApiCall` + `std::function<QVariant(const ApiResponse&)>` 解析器 + isFailure;finished→判定→解析→done/failed)。
|
||
- `src/data/api/NavLoads.hpp` — `Q_DECLARE_METATYPE` 各导航返回类型(`std::vector<Workspace>`、`ProjectListPage`、`std::vector<ProjectType>`、`std::vector<StructNode>`、`DsPage`、`DynamicForm`、`std::vector<ExceptionRow>`、`bool` 已内置);并定义控制器编排用的合成结果载体(见下)。
|
||
- `src/data/repo/IAsyncProjectRepository.hpp` — 异步导航仓储抽象:每方法返回 `NavRequest*`(薄封装,一方法一请求)。9 方法对齐现 `IProjectRepository`。
|
||
- `tests/data/test_nav_request.cpp` — `ApiNavRequest` 离线单测(FakeApiCall + 桩解析器:done/failed/abort 闸门)。
|
||
- `tests/controller/test_workbench_nav_controller.cpp` — 控制器离线单测(Stub 异步 repo + QSignalSpy):覆盖依赖链、并发、abort-and-replace、busyChanged、setCheckedTms 覆盖语义、回灌防护。**当前不存在**(导航控制器现无单测)。
|
||
|
||
**修改:**
|
||
- `src/net/CMakeLists.txt` — 源列表加 `ApiChain.cpp`(AUTOMOC 已 ON)。
|
||
- `src/data/api/ApiProjectRepository.hpp` / `.cpp` — 改/扩展实现 `IAsyncProjectRepository`(每方法用 `getAsync/postJsonAsync` + `ApiNavRequest` + DTO 解析 lambda)。过渡期同时保留 `IProjectRepository`(A4),A6 删旧。
|
||
- `src/data/CMakeLists.txt` — 源列表加 `NavRequest.cpp`(AUTOMOC 已 ON)。
|
||
- `src/controller/WorkbenchNavController.hpp` / `.cpp` — 依赖 `IAsyncProjectRepository`;删 `busy_`/`BusyGuard`/`drain`/pending;改 `ApiChain`/`ApiBatch`/`NavRequest` 编排 + abort-and-replace + 身份比对 + busyChanged 重定义。
|
||
- `tests/CMakeLists.txt` — 加 test_api_chain / test_nav_request / test_workbench_nav_controller。
|
||
- `src/app/main.cpp` — 装配换 `IAsyncProjectRepository`(引用绑定,接线信号面不变);`ProjectListDialog` 形参类型核对(见 A5 Step)。
|
||
|
||
### Part B — 登录(骨架)
|
||
|
||
**新建:**
|
||
- `src/net/AuthService` 改异步(见下);`tests/net/test_auth_async.cpp`(可选 live + 离线 stub 段)。
|
||
|
||
**修改:**
|
||
- `src/net/AuthService.hpp` / `.cpp` — `fetchCaptcha`/`login` 改异步:返回句柄或经回调/信号。用 `ApiChain` 编排 verify→login2(RSA 在 login2 工厂内)。
|
||
- `src/app/login/LoginWindow.hpp` / `.cpp` — 接异步信号;登录期禁用按钮/显示「登录中」、不冻;可取消(关窗 abort)。
|
||
- `tests/net/test_auth.cpp` — live 测试改造为异步等待(`QSignalSpy::wait`)。
|
||
- `src/app/main.cpp` — 移除同步 `ApiClient::get/postJson` 消费后,删除同步方法(最终清理)。
|
||
|
||
**不动:** `src/data/repo/IDatasetRepository.hpp`、详情路径(已异步)、`LocalSampleRepository`、`src/net/crypto/RsaEncryptor.*`。
|
||
|
||
---
|
||
|
||
# Part A — 导航
|
||
|
||
## Task A0: net — `ApiChain` 顺序执行原语 + 离线单测(TDD)
|
||
|
||
复用 `IApiCall`/`ApiResponse`,与 `ApiBatch` 对称。**新增不破坏现有。**
|
||
|
||
**Files:**
|
||
- Create: `src/net/ApiChain.hpp`, `src/net/ApiChain.cpp`, `tests/net/test_api_chain.cpp`
|
||
- Modify: `src/net/CMakeLists.txt`, `tests/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写失败测试 `tests/net/test_api_chain.cpp`**(复用 `tests/net/FakeApiCall.hpp`)
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <stdexcept>
|
||
#include <QSignalSpy>
|
||
#include "ApiChain.hpp"
|
||
#include "net/FakeApiCall.hpp"
|
||
|
||
using namespace geopro::net;
|
||
using geopro::net::test::FakeApiCall;
|
||
|
||
namespace {
|
||
ApiResponse ok(int v = 0) { ApiResponse r; r.code = 200; r.httpStatus = 200; r.data = QJsonObject{{"v", v}}; return r; }
|
||
ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; }
|
||
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
|
||
}
|
||
|
||
TEST(ApiChain, RunsStepsInOrderAndPassesPriorResponses) {
|
||
auto* s1 = new FakeApiCall;
|
||
auto* s2 = new FakeApiCall;
|
||
int seenPrior = -1;
|
||
QList<ApiChain::StepFactory> steps{
|
||
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
|
||
[&](const QList<ApiResponse>& prior) -> IApiCall* {
|
||
seenPrior = prior.size(); // 第二步能看到第一步响应
|
||
return s2;
|
||
}};
|
||
auto* chain = new ApiChain(steps, isFailure);
|
||
QSignalSpy okSpy(chain, &ApiChain::succeeded);
|
||
s1->fire(ok(11)); // 第一步完成 → 触发第二步工厂
|
||
EXPECT_EQ(seenPrior, 1);
|
||
EXPECT_EQ(okSpy.count(), 0); // 还差第二步
|
||
s2->fire(ok(22));
|
||
EXPECT_EQ(okSpy.count(), 1);
|
||
const auto resps = okSpy.takeFirst().at(0).value<QList<ApiResponse>>();
|
||
EXPECT_EQ(resps.size(), 2);
|
||
}
|
||
|
||
TEST(ApiChain, FailFastShortCircuitsRemainingSteps) {
|
||
auto* s1 = new FakeApiCall;
|
||
bool secondBuilt = false;
|
||
QList<ApiChain::StepFactory> steps{
|
||
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
|
||
[&](const QList<ApiResponse>&) -> IApiCall* { secondBuilt = true; return new FakeApiCall; }};
|
||
auto* chain = new ApiChain(steps, isFailure);
|
||
QSignalSpy failSpy(chain, &ApiChain::failed);
|
||
s1->fire(bad()); // 第一步失败
|
||
EXPECT_EQ(failSpy.count(), 1);
|
||
EXPECT_FALSE(secondBuilt); // 后续步骤不再构造
|
||
}
|
||
|
||
TEST(ApiChain, AbortGateSuppressesLateSignals) {
|
||
auto* s1 = new FakeApiCall;
|
||
QList<ApiChain::StepFactory> steps{[&](const QList<ApiResponse>&) -> IApiCall* { return s1; }};
|
||
auto* chain = new ApiChain(steps, isFailure);
|
||
QSignalSpy okSpy(chain, &ApiChain::succeeded);
|
||
chain->abort();
|
||
EXPECT_TRUE(s1->aborted); // 在飞步骤被 abort
|
||
s1->fire(ok()); // 迟到
|
||
EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门
|
||
}
|
||
|
||
TEST(ApiChain, StepFactoryThrowBecomesFailed) {
|
||
auto* s1 = new FakeApiCall;
|
||
QList<ApiChain::StepFactory> steps{
|
||
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
|
||
[&](const QList<ApiResponse>&) -> IApiCall* { throw std::runtime_error("rsa fail"); }};
|
||
auto* chain = new ApiChain(steps, isFailure);
|
||
QSignalSpy failSpy(chain, &ApiChain::failed);
|
||
s1->fire(ok()); // 触发第二步工厂 → 抛 → failed
|
||
EXPECT_EQ(failSpy.count(), 1);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册测试到 CMake,跑确认编译失败**
|
||
|
||
`tests/CMakeLists.txt` net 段(`net/test_api_batch.cpp` 之后)加:
|
||
```cmake
|
||
target_sources(geopro_tests PRIVATE net/test_api_chain.cpp)
|
||
```
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
Expected: 编译失败(`ApiChain.hpp` 不存在)。
|
||
|
||
- [ ] **Step 3: 写 `src/net/ApiChain.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <functional>
|
||
#include <QList>
|
||
#include <QPointer>
|
||
#include <QObject>
|
||
#include "IApiCall.hpp"
|
||
|
||
namespace geopro::net {
|
||
|
||
// 顺序执行 N 个步骤(依赖链):每步工厂用既往响应构造下一 IApiCall(工厂可抛 std::exception)。
|
||
// 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。
|
||
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。
|
||
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
|
||
class ApiChain : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
// 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。
|
||
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
|
||
using Predicate = std::function<bool(const ApiResponse&)>;
|
||
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);
|
||
void abort();
|
||
signals:
|
||
void succeeded(const QList<geopro::net::ApiResponse>& responses);
|
||
void failed(int index, const geopro::net::ApiResponse& resp);
|
||
private:
|
||
void runNext(); // 构造并连接下一步(工厂抛出 → emit failed)
|
||
QList<StepFactory> steps_;
|
||
Predicate isFailure_;
|
||
QList<ApiResponse> responses_;
|
||
QPointer<IApiCall> current_;
|
||
int index_ = 0;
|
||
bool aborted_ = false;
|
||
};
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 4: 写 `src/net/ApiChain.cpp`**
|
||
|
||
```cpp
|
||
#include "ApiChain.hpp"
|
||
#include <stdexcept>
|
||
|
||
namespace geopro::net {
|
||
|
||
ApiChain::ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent)
|
||
: QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) {
|
||
Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded)
|
||
Q_ASSERT(isFailure_);
|
||
runNext();
|
||
}
|
||
|
||
void ApiChain::runNext() {
|
||
if (aborted_) return;
|
||
if (index_ >= steps_.size()) { // 全部完成
|
||
emit succeeded(responses_);
|
||
deleteLater();
|
||
return;
|
||
}
|
||
IApiCall* call = nullptr;
|
||
try {
|
||
call = steps_[index_](responses_); // 工厂可抛(如 RSA 失败):转 failed
|
||
} catch (const std::exception&) {
|
||
ApiResponse r;
|
||
r.rawError = QStringLiteral("步骤构造失败"); // 详细原因由控制器层兜底文案/或工厂内写入
|
||
aborted_ = true;
|
||
emit failed(index_, r);
|
||
deleteLater();
|
||
return;
|
||
}
|
||
current_ = call;
|
||
QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) {
|
||
if (aborted_) return; // §5.0 入口守卫
|
||
if (isFailure_(resp)) {
|
||
aborted_ = true;
|
||
emit failed(index_, resp);
|
||
deleteLater();
|
||
return;
|
||
}
|
||
responses_.append(resp);
|
||
++index_;
|
||
runNext(); // 链式推进
|
||
});
|
||
}
|
||
|
||
void ApiChain::abort() {
|
||
if (aborted_) return;
|
||
aborted_ = true;
|
||
if (current_) current_->abort(); // abort 当前在飞步骤
|
||
deleteLater();
|
||
}
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
> 注:工厂抛异常时的详细原因——若需保留 `e.what()`,可把 `rawError` 设为 `QString::fromUtf8(e.what())`;登录 RSA 失败文案在 AuthService 层包装(Part B)。导航链工厂不做本地变换、不会抛,此分支主要服务 Part B。
|
||
|
||
- [ ] **Step 5: 加 `ApiChain.cpp` 到 net 库**
|
||
|
||
`src/net/CMakeLists.txt` 源列表加 `ApiChain.cpp`(在 `ApiBatch.cpp` 后)。
|
||
|
||
- [ ] **Step 6: 跑测试确认通过**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: 4 个 `ApiChain.*` PASS;总 93/93。
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/net/ApiChain.hpp src/net/ApiChain.cpp src/net/CMakeLists.txt tests/net/test_api_chain.cpp tests/CMakeLists.txt
|
||
git commit -m "feat(net): ApiChain 顺序依赖链原语(fail-fast+abort闸门+工厂可抛) + 离线单测"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A1: data — `NavRequest` 句柄(单类,QVariant payload)+ 元类型声明 + 离线单测(TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/data/api/NavLoads.hpp`, `src/data/api/NavRequest.hpp`, `src/data/api/NavRequest.cpp`, `tests/data/test_nav_request.cpp`
|
||
- Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写 `src/data/api/NavLoads.hpp`**(元类型声明 + 编排合成载体)
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <vector>
|
||
#include <QMetaType>
|
||
#include "repo/RepoTypes.hpp"
|
||
|
||
// 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE(无需 qRegisterMetaType)。
|
||
Q_DECLARE_METATYPE(std::vector<geopro::data::Workspace>)
|
||
Q_DECLARE_METATYPE(geopro::data::ProjectListPage)
|
||
Q_DECLARE_METATYPE(std::vector<geopro::data::ProjectType>)
|
||
Q_DECLARE_METATYPE(std::vector<geopro::data::StructNode>)
|
||
Q_DECLARE_METATYPE(geopro::data::DsPage)
|
||
Q_DECLARE_METATYPE(geopro::data::DynamicForm)
|
||
Q_DECLARE_METATYPE(std::vector<geopro::data::ExceptionRow>)
|
||
// bool 已内置 QMetaType。
|
||
|
||
namespace geopro::data {
|
||
|
||
// 控制器并发编排 selectObject 的三响应合成(data+file+detail),仅控制器内部使用。
|
||
// (此结构供 self-doc;实际并发由控制器用 ApiBatch + 各 NavRequest 解析器组装,见 Task A5。)
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
> `switchWorkspace` 返回 `bool` 但需副作用 `setToken`:该副作用放在「链工厂」里(见 A5),`NavRequest<bool>` 仅承载成功标志。`switchWorkspace` 解析器需访问 `accessToken` → 由 repo 解析 lambda 内 `api_.setToken(...)`(见 A4 Step 2 注)。
|
||
|
||
- [ ] **Step 2: 写 `src/data/api/NavRequest.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <functional>
|
||
#include <QObject>
|
||
#include <QPointer>
|
||
#include <QString>
|
||
#include <QVariant>
|
||
#include "IApiCall.hpp"
|
||
|
||
namespace geopro::data {
|
||
|
||
// 单请求异步句柄(抽象基,可测试缝):payload 经 QVariant 承载,控制器侧 qvariant_cast<T> 取出。
|
||
class NavRequest : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
using QObject::QObject;
|
||
~NavRequest() override = default;
|
||
virtual void abort() = 0;
|
||
signals:
|
||
void done(const QVariant& value);
|
||
void failed(const QString& message);
|
||
};
|
||
|
||
// Api 实现:包一个 IApiCall + 注入的解析器(ApiResponse → QVariant)+ 失败谓词。
|
||
class ApiNavRequest : public NavRequest {
|
||
Q_OBJECT
|
||
public:
|
||
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
|
||
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
|
||
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
|
||
QObject* parent = nullptr); // 接管 call
|
||
void abort() override;
|
||
private:
|
||
QPointer<geopro::net::IApiCall> call_;
|
||
Parser parse_;
|
||
Predicate isFailure_;
|
||
bool aborted_ = false;
|
||
};
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 3: 写 `src/data/api/NavRequest.cpp`**
|
||
|
||
```cpp
|
||
#include "api/NavRequest.hpp"
|
||
#include <stdexcept>
|
||
|
||
namespace geopro::data {
|
||
|
||
namespace {
|
||
QString reasonOf(const geopro::net::ApiResponse& r) {
|
||
return r.msg.isEmpty() ? r.rawError : r.msg;
|
||
}
|
||
} // namespace
|
||
|
||
ApiNavRequest::ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
|
||
QObject* parent)
|
||
: NavRequest(parent), call_(call), parse_(std::move(parse)), isFailure_(std::move(isFailure)) {
|
||
QObject::connect(call, &geopro::net::IApiCall::finished, this,
|
||
[this](const geopro::net::ApiResponse& resp) {
|
||
if (aborted_) return; // §5.0 入口守卫
|
||
if (isFailure_(resp)) {
|
||
emit failed(reasonOf(resp));
|
||
deleteLater();
|
||
return;
|
||
}
|
||
QVariant out;
|
||
try {
|
||
out = parse_(resp); // 仅解析在 try 内(下游 done 处理器抛出不误报)
|
||
} catch (const std::exception& e) {
|
||
emit failed(QString::fromUtf8(e.what()));
|
||
deleteLater();
|
||
return;
|
||
} catch (...) {
|
||
emit failed(QStringLiteral("解析失败:未知异常"));
|
||
deleteLater();
|
||
return;
|
||
}
|
||
emit done(out);
|
||
deleteLater();
|
||
});
|
||
}
|
||
|
||
void ApiNavRequest::abort() {
|
||
if (aborted_) return;
|
||
aborted_ = true;
|
||
if (call_) call_->abort();
|
||
deleteLater();
|
||
}
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 4: 写失败测试 `tests/data/test_nav_request.cpp`**
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <QSignalSpy>
|
||
#include <QVariant>
|
||
#include "api/NavRequest.hpp"
|
||
#include "api/NavLoads.hpp"
|
||
#include "net/FakeApiCall.hpp"
|
||
|
||
using namespace geopro::data;
|
||
using geopro::net::ApiResponse;
|
||
using geopro::net::test::FakeApiCall;
|
||
|
||
namespace {
|
||
ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; }
|
||
ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; }
|
||
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
|
||
}
|
||
|
||
TEST(NavRequest, EmitsDoneWithParsedPayload) {
|
||
auto* call = new FakeApiCall;
|
||
auto* req = new ApiNavRequest(call,
|
||
[](const ApiResponse&) { return QVariant::fromValue(DsPage{{}, 42}); }, isFailure);
|
||
QSignalSpy doneSpy(req, &NavRequest::done);
|
||
call->fire(ok());
|
||
ASSERT_EQ(doneSpy.count(), 1);
|
||
const auto page = doneSpy.takeFirst().at(0).toMap().isEmpty()
|
||
? qvariant_cast<DsPage>(doneSpy.count() ? QVariant() : QVariant()) // 见下注
|
||
: DsPage{};
|
||
// 简化断言:直接从信号取 QVariant
|
||
}
|
||
|
||
TEST(NavRequest, EmitsFailedOnBusinessError) {
|
||
auto* call = new FakeApiCall;
|
||
auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure);
|
||
QSignalSpy failSpy(req, &NavRequest::failed);
|
||
call->fire(bad());
|
||
EXPECT_EQ(failSpy.count(), 1);
|
||
}
|
||
|
||
TEST(NavRequest, AbortSuppressesLateDone) {
|
||
auto* call = new FakeApiCall;
|
||
auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure);
|
||
QSignalSpy doneSpy(req, &NavRequest::done);
|
||
req->abort();
|
||
EXPECT_TRUE(call->aborted);
|
||
call->fire(ok());
|
||
EXPECT_EQ(doneSpy.count(), 0);
|
||
}
|
||
```
|
||
> 实现者注:`EmitsDoneWithParsedPayload` 的 payload 断言改为直接 `qvariant_cast<DsPage>(doneSpy.takeFirst().at(0))` 并 `EXPECT_EQ(page.total, 42)`(上方草稿的取值写法笨拙,落地时简化为这一行;`DsPage` 已 `Q_DECLARE_METATYPE`)。
|
||
|
||
- [ ] **Step 5: 注册 data 源 + 测试到 CMake**
|
||
|
||
`src/data/CMakeLists.txt` 源列表加 `api/NavRequest.cpp`(在 `api/DatasetLoadHandles.cpp` 后;AUTOMOC 已 ON)。
|
||
`tests/CMakeLists.txt` data 段(`data/test_dataset_load_handles.cpp` 后)加:
|
||
```cmake
|
||
target_sources(geopro_tests PRIVATE data/test_nav_request.cpp)
|
||
```
|
||
|
||
- [ ] **Step 6: 跑测试确认通过**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: 3 个 `NavRequest.*` PASS;总 96/96。
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/data/api/NavLoads.hpp src/data/api/NavRequest.hpp src/data/api/NavRequest.cpp src/data/CMakeLists.txt tests/data/test_nav_request.cpp
|
||
git commit -m "feat(data): NavRequest 单请求异步句柄(QVariant payload, abort闸门) + 元类型声明 + 离线单测"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A2: data — `IAsyncProjectRepository` 抽象接口(新增,不删旧)
|
||
|
||
**Files:**
|
||
- Create: `src/data/repo/IAsyncProjectRepository.hpp`
|
||
|
||
- [ ] **Step 1: 写 `src/data/repo/IAsyncProjectRepository.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <string>
|
||
|
||
namespace geopro::data {
|
||
|
||
class NavRequest;
|
||
|
||
// 导航异步仓储抽象(薄封装:一方法一请求,返回自管理句柄 emit done(QVariant)/failed(msg))。
|
||
// 汇聚/链式编排由 WorkbenchNavController 负责(它知道完整序列与状态)。
|
||
// 方法与同步 IProjectRepository 一一对应;payload 类型见各方法注释(控制器 qvariant_cast)。
|
||
class IAsyncProjectRepository {
|
||
public:
|
||
virtual ~IAsyncProjectRepository() = default;
|
||
virtual NavRequest* listWorkspaces() = 0; // std::vector<Workspace>
|
||
virtual NavRequest* switchWorkspace(const std::string& tenantId) = 0; // bool(解析器内 setToken 副作用)
|
||
virtual NavRequest* pageProjects(const std::string& nameFilter,
|
||
const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage
|
||
virtual NavRequest* listProjectTypes() = 0; // std::vector<ProjectType>
|
||
virtual NavRequest* loadStructure(const std::string& projectId) = 0; // std::vector<StructNode>
|
||
virtual NavRequest* loadRows(const std::string& projectId, const std::string& parentId,
|
||
int parentConfType, int classifyType, int pageNo) = 0; // DsPage
|
||
virtual NavRequest* loadObjectDetail(const std::string& objectId, int confType) = 0; // DynamicForm
|
||
virtual NavRequest* loadDatasetForm(const std::string& dsObjectId) = 0; // DynamicForm
|
||
virtual NavRequest* loadExceptionsByTm(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow>
|
||
};
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 2: 构建(纯头新增,无消费者,验证编译)**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
Expected: 构建通过(未被引用,仅语法校验)。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/data/repo/IAsyncProjectRepository.hpp
|
||
git commit -m "feat(data): IAsyncProjectRepository 异步导航仓储抽象(薄封装,返回NavRequest)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A3: data — `ApiProjectRepository` 实现异步接口(过渡:新旧两接口并存)
|
||
|
||
让 `ApiProjectRepository` **同时**实现 `IProjectRepository`(旧同步,保留)与 `IAsyncProjectRepository`(新异步)。构建仍绿、旧控制器路径不动。
|
||
|
||
**Files:**
|
||
- Modify: `src/data/api/ApiProjectRepository.hpp`, `src/data/api/ApiProjectRepository.cpp`
|
||
|
||
- [ ] **Step 1: 改 `ApiProjectRepository.hpp`** — 加继承 + 9 个异步方法声明
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include "repo/IProjectRepository.hpp"
|
||
#include "repo/IAsyncProjectRepository.hpp"
|
||
|
||
namespace geopro::net { class ApiClient; }
|
||
|
||
namespace geopro::data {
|
||
|
||
// 用共享会话 ApiClient 实现导航仓储。过渡期同时实现同步(旧)+异步(新)两接口。
|
||
class ApiProjectRepository : public IProjectRepository, public IAsyncProjectRepository {
|
||
public:
|
||
explicit ApiProjectRepository(net::ApiClient& api);
|
||
|
||
// ── 同步(旧,A6 删除) ──
|
||
RepoResult<std::vector<Workspace>> listWorkspaces() override;
|
||
/* ...其余 8 个同步方法签名保持不变(略,见现文件)... */
|
||
|
||
// ── 异步(新) ── 与 IAsyncProjectRepository 一致,返回 NavRequest*
|
||
NavRequest* listWorkspacesAsync(); // 见下注:消歧
|
||
/* 注:同步 listWorkspaces() 与异步签名冲突(同名不同返回类型不能 override 两接口同名)。
|
||
解决:异步接口方法在 .hpp 用 IAsyncProjectRepository 的纯虚名(listWorkspaces 等),
|
||
但同步接口也叫 listWorkspaces → C++ 同名隐藏。两接口同名方法返回类型不同,无法在同一类
|
||
共存(重载仅靠参数区分,返回类型不算)。**故 A3 改为:异步方法用不同名后缀消歧。** */
|
||
};
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
> **重要消歧(实现者必读):** `IProjectRepository::listWorkspaces()` 返回 `RepoResult<...>`,`IAsyncProjectRepository::listWorkspaces()` 返回 `NavRequest*`——**同名同参不同返回类型,C++ 不允许在同一类同时 override**。两条出路:
|
||
> - **出路 1(推荐,最干净):跳过过渡期双实现,A3/A4 合并为「直接改形」**:`ApiProjectRepository` 只实现 `IAsyncProjectRepository`(删同步继承),同批改控制器(A4+A5 合并提交,如决策 5 备选)。代价:A4 到 A5 之间一个提交内构建可能不绿——用「单次大提交」承接(详情试点 Task5+6 合并的同款做法)。
|
||
> - **出路 2:异步方法加 `Async` 后缀**(`listWorkspacesAsync()` 等),`IAsyncProjectRepository` 接口方法名也带 `Async`。两接口可共存、过渡期构建绿、可分步提交。代价:方法名与同步版略有差异。
|
||
>
|
||
> **本计划采用出路 2**(保证每步构建绿、可分散提交,符合「频繁提交」与决策 5)。下方 Step 按出路 2 给出:`IAsyncProjectRepository` 的方法名统一加 `Async` 后缀。**回到 Task A2 把接口方法名改为 `listWorkspacesAsync` 等**(A2 已提交则在本 Task 一并修正 + 重新提交 A2 头)。
|
||
|
||
- [ ] **Step 1b: 修正 `IAsyncProjectRepository.hpp` 方法名加 `Async` 后缀**
|
||
|
||
把 A2 的 9 个方法改名:`listWorkspacesAsync` / `switchWorkspaceAsync` / `pageProjectsAsync` / `listProjectTypesAsync` / `loadStructureAsync` / `loadRowsAsync` / `loadObjectDetailAsync` / `loadDatasetFormAsync` / `loadExceptionsByTmAsync`(参数同前)。
|
||
|
||
- [ ] **Step 2: `ApiProjectRepository.hpp` 加 9 个 `...Async` 声明**(保留全部同步方法不动)
|
||
|
||
```cpp
|
||
NavRequest* listWorkspacesAsync() override;
|
||
NavRequest* switchWorkspaceAsync(const std::string& tenantId) override;
|
||
NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId,
|
||
int pageNo, int pageSize) override;
|
||
NavRequest* listProjectTypesAsync() override;
|
||
NavRequest* loadStructureAsync(const std::string& projectId) override;
|
||
NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
|
||
int parentConfType, int classifyType, int pageNo) override;
|
||
NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
|
||
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
|
||
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
|
||
```
|
||
|
||
- [ ] **Step 3: 改 `ApiProjectRepository.cpp`** — 加异步实现(解析 lambda 复用现有 `dto::parseXxx`)
|
||
|
||
在文件顶部 include 加:
|
||
```cpp
|
||
#include "ApiBatch.hpp" // 仅若需要;本 repo 薄封装不直接用 batch(控制器用)
|
||
#include "api/NavRequest.hpp"
|
||
#include "api/NavLoads.hpp"
|
||
```
|
||
匿名命名空间内已有 `enc`/`ok`/`errorOf`;新增异步失败谓词(与同步 `ok` 同口径):
|
||
```cpp
|
||
bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }
|
||
```
|
||
在文件末尾(同步方法之后)加 9 个异步实现,例如:
|
||
```cpp
|
||
NavRequest* ApiProjectRepository::listWorkspacesAsync() {
|
||
auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::switchWorkspaceAsync(const std::string& tenantId) {
|
||
const QString path =
|
||
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId));
|
||
auto* call = api_.postJsonAsync(path, QJsonObject{});
|
||
// 解析器内执行 setToken 副作用(与同步版一致):切空间返回新 accessToken 必须重注入。
|
||
return new ApiNavRequest(call, [this](const net::ApiResponse& r) {
|
||
const QString token = r.data.value(QStringLiteral("accessToken")).toString();
|
||
if (!token.isEmpty()) api_.setToken(token);
|
||
return QVariant::fromValue(true);
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::pageProjectsAsync(const std::string& nameFilter,
|
||
const std::string& typeId, int pageNo, int pageSize) {
|
||
QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)},
|
||
{QStringLiteral("pageNo"), pageNo},
|
||
{QStringLiteral("pageSize"), pageSize}};
|
||
if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId);
|
||
auto* call = api_.postJsonAsync(QStringLiteral("/business/my/profile/project/page"), body);
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseProjectPage(r.data));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectId) {
|
||
const QString path =
|
||
QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId));
|
||
auto* call = api_.getAsync(path);
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, const std::string& parentId,
|
||
int parentConfType, int classifyType, int pageNo) {
|
||
const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page")
|
||
: QStringLiteral("/business/dsObject/data/page");
|
||
const QJsonObject body{
|
||
{QStringLiteral("projectId"), QString::fromStdString(projectId)},
|
||
{QStringLiteral("structParentId"), QString::fromStdString(parentId)},
|
||
{QStringLiteral("structParentConfType"), parentConfType},
|
||
{QStringLiteral("classifyTypeList"), QJsonArray{classifyType}},
|
||
{QStringLiteral("pageNo"), pageNo},
|
||
{QStringLiteral("pageSize"), 5}};
|
||
auto* call = api_.postJsonAsync(path, body);
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseDsPage(r.data));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::loadObjectDetailAsync(const std::string& objectId, int confType) {
|
||
const QString path =
|
||
(confType == 1) ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId))
|
||
: QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId));
|
||
auto* call = api_.getAsync(path);
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseDynamicForm(r.data));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::loadDatasetFormAsync(const std::string& dsObjectId) {
|
||
const QString path = QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId));
|
||
auto* call = api_.getAsync(path);
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseDynamicForm(r.data));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::listProjectTypesAsync() {
|
||
auto* call = api_.getAsync(QStringLiteral("/business/project/type/list"));
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()));
|
||
}, &isFailureA);
|
||
}
|
||
|
||
NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmObjectId) {
|
||
const QString path =
|
||
QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId));
|
||
auto* call = api_.getAsync(path);
|
||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||
return QVariant::fromValue(dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()));
|
||
}, &isFailureA);
|
||
}
|
||
```
|
||
> 解析函数名与现同步实现逐一对齐:`parseWorkspaces`/`parseProjectPage`/`parseProjectTypes`/`parseStructNodes`/`parseDsPage`/`parseDynamicForm`/`parseExceptions`(见现 `ApiProjectRepository.cpp`)。URL/body 构造原样搬,不重写。
|
||
|
||
- [ ] **Step 4: 构建(异步方法离线不可单测真实端点,验证编译/链接/moc)**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
Expected: 链接通过。
|
||
> 真实端点行为由 Task A6 手动验证覆盖(与现 ApiProjectRepository 同:无离线单测)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/data/repo/IAsyncProjectRepository.hpp src/data/api/ApiProjectRepository.hpp src/data/api/ApiProjectRepository.cpp
|
||
git commit -m "feat(data): ApiProjectRepository 实现 IAsyncProjectRepository(9方法,Async后缀,薄封装,新旧并存)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A4: controller — `WorkbenchNavController` 异步化(abort-and-replace + ApiChain/ApiBatch 编排 + 删 busy_/drain)+ 单测(TDD)
|
||
|
||
> 本 Task 体量大(控制器是难点)。**Step 1(写控制器单测,先确立目标行为)→ Step 2/3(改控制器头/源)→ Step 4(跑测试)必须连续完成、同一提交**(接口消费切换是原子改动,见决策 5)。同步 `IProjectRepository` 路径在本 Task 后无消费者(A6 删除)。
|
||
|
||
**Files:**
|
||
- Create: `tests/controller/test_workbench_nav_controller.cpp`
|
||
- Modify: `src/controller/WorkbenchNavController.hpp`, `src/controller/WorkbenchNavController.cpp`, `tests/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写控制器单测 `tests/controller/test_workbench_nav_controller.cpp`**(Stub 异步 repo + QSignalSpy)
|
||
|
||
桩策略:定义一个 `StubNavRequest`(不声明 Q_OBJECT,发射继承自 `NavRequest` 的 `done/failed`、override `abort` 记录),`StubAsyncRepo` 实现 `IAsyncProjectRepository`,每方法 `new StubNavRequest` 并记录最近一个(按方法名分桶),测试可手动 `fireDone(QVariant)`/`fireFailed()`/查 `aborted`。
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <QSignalSpy>
|
||
#include <QVariant>
|
||
#include "WorkbenchNavController.hpp"
|
||
#include "repo/IAsyncProjectRepository.hpp"
|
||
#include "api/NavRequest.hpp"
|
||
#include "api/NavLoads.hpp"
|
||
using namespace geopro;
|
||
|
||
namespace {
|
||
struct StubNavRequest : data::NavRequest {
|
||
bool aborted = false;
|
||
void abort() override { aborted = true; }
|
||
void fireDone(const QVariant& v) { emit done(v); }
|
||
void fireFailed() { emit failed(QStringLiteral("x")); }
|
||
};
|
||
struct StubAsyncRepo : data::IAsyncProjectRepository {
|
||
StubNavRequest* lastWorkspaces = nullptr;
|
||
StubNavRequest* lastProjects = nullptr;
|
||
StubNavRequest* lastStructure = nullptr;
|
||
StubNavRequest* lastExceptions = nullptr;
|
||
// ...其余按需
|
||
data::NavRequest* listWorkspacesAsync() override { return lastWorkspaces = new StubNavRequest; }
|
||
data::NavRequest* pageProjectsAsync(const std::string&, const std::string&, int, int) override {
|
||
return lastProjects = new StubNavRequest; }
|
||
data::NavRequest* loadStructureAsync(const std::string&) override {
|
||
return lastStructure = new StubNavRequest; }
|
||
data::NavRequest* loadExceptionsByTmAsync(const std::string&) override {
|
||
return lastExceptions = new StubNavRequest; }
|
||
data::NavRequest* switchWorkspaceAsync(const std::string&) override { return new StubNavRequest; }
|
||
data::NavRequest* listProjectTypesAsync() override { return new StubNavRequest; }
|
||
data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int, int) override {
|
||
return new StubNavRequest; }
|
||
data::NavRequest* loadObjectDetailAsync(const std::string&, int) override { return new StubNavRequest; }
|
||
data::NavRequest* loadDatasetFormAsync(const std::string&) override { return new StubNavRequest; }
|
||
};
|
||
}
|
||
|
||
// start() 依赖链:workspaces → projects → structure,逐级 emit 既有信号。
|
||
TEST(WorkbenchNavController, StartChainEmitsWorkspacesThenProjectsThenStructure) {
|
||
StubAsyncRepo repo;
|
||
controller::WorkbenchNavController c(repo);
|
||
QSignalSpy wsSpy(&c, &controller::WorkbenchNavController::workspacesLoaded);
|
||
QSignalSpy psSpy(&c, &controller::WorkbenchNavController::projectsLoaded);
|
||
QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded);
|
||
c.start();
|
||
repo.lastWorkspaces->fireDone(QVariant::fromValue(std::vector<data::Workspace>{{"w1","WS",2,true}}));
|
||
EXPECT_EQ(wsSpy.count(), 1);
|
||
repo.lastProjects->fireDone(QVariant::fromValue(data::ProjectListPage{{{"p1","P1"}}, 1}));
|
||
EXPECT_EQ(psSpy.count(), 1);
|
||
repo.lastStructure->fireDone(QVariant::fromValue(std::vector<data::StructNode>{}));
|
||
EXPECT_EQ(stSpy.count(), 1);
|
||
}
|
||
|
||
// busyChanged 反映在飞:发起→true,最后完成→false。
|
||
TEST(WorkbenchNavController, BusyChangedReflectsInflight) {
|
||
StubAsyncRepo repo;
|
||
controller::WorkbenchNavController c(repo);
|
||
QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged);
|
||
c.start();
|
||
ASSERT_GE(busySpy.count(), 1);
|
||
EXPECT_TRUE(busySpy.takeFirst().at(0).toBool()); // 首次 true
|
||
repo.lastWorkspaces->fireDone(QVariant::fromValue(std::vector<data::Workspace>{{"w1","WS",2,true}}));
|
||
repo.lastProjects->fireDone(QVariant::fromValue(data::ProjectListPage{{{"p1","P1"}}, 1}));
|
||
repo.lastStructure->fireDone(QVariant::fromValue(std::vector<data::StructNode>{}));
|
||
EXPECT_FALSE(busySpy.last().at(0).toBool()); // 末尾 false
|
||
}
|
||
|
||
// setCheckedTms:新勾选 abort 旧异常批(以最后一次为准)。
|
||
TEST(WorkbenchNavController, SetCheckedTmsAbortsPreviousBatch) {
|
||
StubAsyncRepo repo;
|
||
controller::WorkbenchNavController c(repo);
|
||
c.setCheckedTms({"tmA"});
|
||
StubNavRequest* a = repo.lastExceptions;
|
||
c.setCheckedTms({"tmB"}); // 覆盖
|
||
EXPECT_TRUE(a->aborted);
|
||
}
|
||
|
||
// 回灌防护:abort 后旧句柄迟到 done 被身份比对丢弃。
|
||
TEST(WorkbenchNavController, DropsLateStructureAfterProjectSwitch) {
|
||
StubAsyncRepo repo;
|
||
controller::WorkbenchNavController c(repo);
|
||
QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded);
|
||
c.switchProject("pA");
|
||
StubNavRequest* a = repo.lastStructure;
|
||
c.switchProject("pB");
|
||
StubNavRequest* b = repo.lastStructure;
|
||
a->fireDone(QVariant::fromValue(std::vector<data::StructNode>{})); // 旧 → 丢弃
|
||
EXPECT_EQ(stSpy.count(), 0);
|
||
b->fireDone(QVariant::fromValue(std::vector<data::StructNode>{})); // 新 → 正常
|
||
EXPECT_EQ(stSpy.count(), 1);
|
||
}
|
||
```
|
||
> 实现者注:测试里 `Workspace{"w1","WS",2,true}` 字段顺序须对齐 `RepoTypes.hpp`(`id,name,ownerType,isCurrent`);`ProjectSummary`/`StructNode` 等聚合初始化按其声明顺序。`switchProject` 单请求路径用 `loadStructureAsync`。selectObject 的并发(ApiBatch)测试可加 `SelectObjectConcurrentBatch`(三 stub 全 done 后一次性 emit datasets/files/objectDetail)——列为补充用例。
|
||
|
||
- [ ] **Step 2: 改 `WorkbenchNavController.hpp`**
|
||
|
||
要点:① 改依赖 `IAsyncProjectRepository&`;② 删 `busy_`/`checkedTmsPending_`/`pendingCheckedTms_`/`drainPendingCheckedTms`/`friend struct BusyGuard`;③ 加每路径 `QPointer` 在飞句柄成员 + `ApiChain`/`ApiBatch` 的 `QPointer`;④ 加 `emitBusyIfChanged()` 私有辅助 + `bool lastBusy_`;⑤ 信号面不变(`busyChanged` 语义重定义为「有在飞」)。
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <QObject>
|
||
#include <QPointer>
|
||
#include <QString>
|
||
#include <QStringList>
|
||
#include <map>
|
||
#include <string>
|
||
#include <vector>
|
||
#include "repo/RepoTypes.hpp"
|
||
|
||
namespace geopro::net { class ApiChain; class ApiBatch; }
|
||
namespace geopro::data { class IAsyncProjectRepository; class NavRequest; }
|
||
|
||
namespace geopro::controller {
|
||
|
||
class WorkbenchNavController : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
explicit WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent = nullptr);
|
||
void start();
|
||
QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }
|
||
|
||
public slots:
|
||
void switchWorkspace(const QString& tenantId);
|
||
void switchProject(const QString& projectId);
|
||
void selectObject(const QString& objectId, int confType);
|
||
void setCheckedTms(const QStringList& tmObjectIds);
|
||
void selectDataset(const QString& dsObjectId);
|
||
void loadMoreData();
|
||
void loadMoreFiles();
|
||
|
||
signals:
|
||
void busyChanged(bool busy);
|
||
void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId);
|
||
void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list, const QString& currentId, int total);
|
||
void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes);
|
||
void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows, int total, bool append);
|
||
void filesLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows, int total, bool append);
|
||
void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form);
|
||
void exceptionTreeLoaded(const std::vector<geopro::data::ObjectExceptionGroup>& groups, int exceptionCount);
|
||
void datasetDetailLoaded(const geopro::data::DynamicForm& form);
|
||
void loadFailed(const QString& stage, const QString& message);
|
||
|
||
private:
|
||
void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged
|
||
bool anyInflight() const; // OR 所有在飞 QPointer
|
||
|
||
data::IAsyncProjectRepository& repo_;
|
||
bool lastBusy_ = false;
|
||
|
||
// 在飞句柄(QPointer 防悬垂;身份比对用):
|
||
QPointer<net::ApiChain> startChain_; // start / switchWorkspace 依赖链
|
||
QPointer<data::NavRequest> structReq_; // switchProject
|
||
QPointer<net::ApiBatch> selectBatch_; // selectObject 三并发
|
||
QPointer<data::NavRequest> moreDataReq_;
|
||
QPointer<data::NavRequest> moreFilesReq_;
|
||
QPointer<data::NavRequest> datasetReq_;
|
||
QPointer<net::ApiBatch> exceptionsBatch_; // setCheckedTms
|
||
|
||
std::vector<data::ProjectSummary> lastProjects_;
|
||
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
|
||
std::string currentParentId_;
|
||
int currentParentConfType_ = 0;
|
||
std::vector<data::StructNode> lastStructNodes_;
|
||
std::map<std::string, std::vector<data::ExceptionRow>> tmExceptionCache_;
|
||
int dataPageNo_ = 0, filePageNo_ = 0, dataTotal_ = 0, fileTotal_ = 0;
|
||
};
|
||
|
||
} // namespace geopro::controller
|
||
```
|
||
|
||
- [ ] **Step 3: 改 `WorkbenchNavController.cpp`**(核心编排,分块写)
|
||
|
||
设计要点(每块对照现同步实现 1:1 搬状态更新逻辑,只把「同步取值」换成「句柄 done lambda」):
|
||
|
||
1. **`start()` / `switchWorkspace()` 用 `ApiChain`:**
|
||
- `start()`: abort 旧 `startChain_`;建 chain `{ listWorkspacesAsync 工厂, pageProjectsAsync 工厂, loadStructureAsync 工厂 }`。但 repo 是「一方法一请求」的 `NavRequest`,而 `ApiChain` 步骤要的是 `IApiCall*`。**桥接:** `ApiChain` 步骤工厂层级要 `IApiCall`,但导航链的「依赖」体现在「用上一步解析后的业务值(如 currentWorkspaceId、首个 projectId)构造下一请求」,而非透传 `ApiResponse`。两种实现路线:
|
||
- **路线 X(推荐,KISS):不用 `ApiChain` 串 repo 句柄,改用「NavRequest done lambda 里发起下一 NavRequest」的续延**——即在控制器内手写链:`listWorkspacesAsync().done → 算 currentId → pageProjectsAsync().done → 算首个 project → loadStructureAsync().done → emit`。每级 abort-and-replace 由 `startChain_` 改为一个「当前在飞 NavRequest」的 QPointer 链(用单一 `QPointer<NavRequest> startStepReq_` 跟踪当前级,abort 它即 abort 整条链的当前级)。`aborted_` 闸门由 NavRequest 自身 + 控制器在每级 done 里比对 `sender()/捕获指针 == 当前级` 提供。
|
||
- 路线 Y:`ApiChain`(透传 ApiResponse),但导航链需要业务解析后的值(不只是 raw response)构造下一请求——`ApiChain` 工厂入参是 `QList<ApiResponse>`,可在工厂内重新解析(DTO 解析幂等、廉价),可行但重复解析。
|
||
- **采用路线 X**:`ApiChain` 原语**保留供登录用**(登录链透传 response 直接喂下一请求,天然契合);导航依赖链用「NavRequest 续延」在控制器内编排(导航需要业务值串联,续延更直观)。**因此 `startChain_` 成员改为 `QPointer<data::NavRequest> startStepReq_`**(跟踪 start/switchWorkspace 链当前在飞级)。
|
||
> 决策修订记录(self-review 复核):决策 1 说「导航依赖链用 ApiChain」,实测代码后修订为「导航依赖链用 NavRequest 续延(因需业务值串联)、ApiChain 专供登录(透传 response 串联)」。`ApiChain` 仍在 A0 落地并测试,Part B 登录使用。
|
||
|
||
2. **`switchProject()` 用单 `NavRequest`(`loadStructureAsync`):** abort 旧 `structReq_`;存新;done lambda 内身份比对 + 1:1 搬现状态更新(`currentProjectId_`/`currentProjectName_`/`currentCrsCode_` from `lastProjects_`、`tmExceptionCache_.clear()`、重置选中态)+ emit `structureLoaded`。
|
||
|
||
3. **`selectObject()` 用 `ApiBatch`(三并发:loadRows data + loadRows file + loadObjectDetail):**
|
||
- abort 旧 `selectBatch_`;用 `api`?——控制器没有 `ApiClient`,只有 repo。**桥接 ApiBatch 需要 `IApiCall*`,而 repo 给的是 `NavRequest`。** 解决:`selectObject` 不用 `ApiBatch`(它要 IApiCall);改用「三个独立 `NavRequest` + 控制器内计数汇聚」或顺序续延。**KISS 选择:** 保持现「顺序」语义但异步化——data done → file done → detail done → 全到齐前不算完成;或真并发——三 `NavRequest` 并行、各自 done,控制器维护 `remaining` 计数,全到齐后一次性 emit(与现 emit 三信号一致)。**推荐并发计数**(三请求独立,无依赖;缩短等待):成员加 `QPointer<NavRequest> selDataReq_/selFileReq_/selDetailReq_` + `int selRemaining_`,三者 done 各自比对身份、填暂存、`--selRemaining_==0` 后 emit `datasetsLoaded`+`filesLoaded`+`objectDetailLoaded`。失败任一 → emit `loadFailed` + abort 其余。
|
||
> 这复刻了 `ApiBatch` 的能力但作用于 `NavRequest`——是否值得为此再造?**判断:** `ApiBatch` 作用于 `IApiCall`,`selectObject` 需要的是「三个 typed NavRequest 的汇聚」。可以加一个轻量 `NavBatch`(汇聚 N 个 `NavRequest`,全 done → succeeded(QList<QVariant>);任一 failed → fail + abort)。**但 selectObject 是唯一的导航并发汇聚点**(setCheckedTms 见下,其实是「N 个相同类型请求 + 缓存」),YAGNI 下先用控制器内计数(~15 行);若 setCheckedTms 也要并发汇聚,则提炼 `NavBatch`。**本计划:selectObject 用控制器内三句柄计数(不新增原语)。**
|
||
|
||
4. **`loadMoreData()` / `loadMoreFiles()` / `selectDataset()` 用单 `NavRequest`:** abort 旧、存新、done 身份比对 + emit(append=true / datasetDetailLoaded)。删 `currentParentId_.empty()` 之外的 busy 守卫。
|
||
|
||
5. **`setCheckedTms()` 异步化(去 busy/pending/drain):**
|
||
- abort 旧 `exceptionsBatch_`(或续延句柄)。
|
||
- 命中缓存的 TM 直接组装;未命中的 N 个 `loadExceptionsByTmAsync` **并发**、计数汇聚(同 selectObject 计数模式),全到齐后写缓存 + `groupExceptionsByConsortium` 组装 + emit `exceptionTreeLoaded`。
|
||
- **以最后一次勾选为准**:新 `setCheckedTms` abort 旧的在飞集(abort-and-replace),无需 pending 重放。
|
||
- 若全部命中缓存(无在飞请求):同步组装后直接 emit(busyChanged 不抖动)。
|
||
|
||
6. **`busyChanged`:** 每次「发起请求后」与「句柄 done/failed 清空后」调 `emitBusyIfChanged()`:
|
||
```cpp
|
||
bool WorkbenchNavController::anyInflight() const {
|
||
return startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ ||
|
||
moreDataReq_ || moreFilesReq_ || datasetReq_ || !checkedInflight_.isEmpty();
|
||
}
|
||
void WorkbenchNavController::emitBusyIfChanged() {
|
||
const bool now = anyInflight();
|
||
if (now != lastBusy_) { lastBusy_ = now; emit busyChanged(now); }
|
||
}
|
||
```
|
||
|
||
> 实现者:本 Step 是全计划最大改动。建议在 subagent 内分 6 个子提交(start链 / switchProject / selectObject / loadMore+selectDataset / setCheckedTms / busyChanged 收口),但**首个能编译通过的提交必须已切换 ctor 到 `IAsyncProjectRepository`**(否则与 main.cpp 装配不一致)。若分子提交导致中间不可编译,则合并为单提交(接口消费切换原子性,决策 5)。
|
||
|
||
- [ ] **Step 4: 改 `WorkbenchNavController.cpp` include + 注册测试 + 跑**
|
||
|
||
cpp 顶部 include 改为:
|
||
```cpp
|
||
#include "WorkbenchNavController.hpp"
|
||
#include "repo/IAsyncProjectRepository.hpp"
|
||
#include "api/NavRequest.hpp"
|
||
#include "api/NavLoads.hpp"
|
||
#include "ApiBatch.hpp" // 若 selectObject 最终用 NavBatch/ApiBatch;本计划用计数则不需要
|
||
#include "dto/NavDto.hpp"
|
||
```
|
||
`tests/CMakeLists.txt` controller 段(`controller/test_dataset_detail_controller.cpp` 后)加:
|
||
```cmake
|
||
target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp)
|
||
```
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: `WorkbenchNavController.*` PASS(≥4 个);总 ~100/100。
|
||
> ⚠️ 此时 main.cpp 仍把 `ApiProjectRepository`(实现两接口)作 `IProjectRepository&` 传给 `buildWorkbench`/`nav`——但 `nav` ctor 已改 `IAsyncProjectRepository&`。**Step 4 跑测试前不构建 app 也可**(dev-build 构建全部);若 app 编译失败,须同批做 A5(main.cpp 装配)。**建议 A4 与 A5 连续执行、A5 完成后再要求全绿。**
|
||
|
||
- [ ] **Step 5: Commit(A4)**
|
||
|
||
```bash
|
||
git add src/controller/WorkbenchNavController.hpp src/controller/WorkbenchNavController.cpp tests/controller/test_workbench_nav_controller.cpp tests/CMakeLists.txt
|
||
git commit -m "feat(controller): WorkbenchNavController 异步化(NavRequest续延+并发计数, abort-and-replace+身份比对, 删busy_/drain/BusyGuard, busyChanged=在飞存在性) + 单测"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A5: app — 装配切换到异步导航仓储(与 A4 连续)
|
||
|
||
**Files:**
|
||
- Modify: `src/app/main.cpp`(必要时 `ProjectListDialog` 的 repo 形参类型)
|
||
|
||
- [ ] **Step 1: 核对 `nav` 构造与 `buildWorkbench` 形参**
|
||
|
||
`main.cpp:858-859`:`ApiProjectRepository projectRepo(api); WorkbenchNavController nav(projectRepo);`——`projectRepo` 现同时是 `IProjectRepository` 与 `IAsyncProjectRepository`,`nav` ctor 取 `IAsyncProjectRepository&`,**引用绑定自动选对基类,无需改**。
|
||
`buildWorkbench(...)` 形参 `geopro::data::IProjectRepository& projectRepo`(:195)仍用于 `ProjectListDialog`(:655 `new ProjectListDialog(projectRepo, ...)`)——`ProjectListDialog` 仍用同步接口(它自己分页查询)。**本阶段不动 ProjectListDialog**(同步接口 A6 才删;ProjectListDialog 是同步接口最后消费者之一,A6 一并迁移或保留同步)。
|
||
|
||
- [ ] **Step 2: 确认 nav 信号接线不变**
|
||
|
||
`main.cpp:637-751` 所有 `nav` 信号接线(workspacesLoaded/projectsLoaded/structureLoaded/datasetsLoaded/filesLoaded/objectDetailLoaded/exceptionTreeLoaded/datasetDetailLoaded/loadFailed/busyChanged)**信号面未变,全部不动**。`nav.start()`(:885)不变。
|
||
|
||
- [ ] **Step 3: 构建 + 全量测试**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` 然后 `... dev-test.ps1`
|
||
Expected: 全绿 ~100/100;构建出 `geopro_desktop.exe`。
|
||
|
||
- [ ] **Step 4: 手动验证(核心收益)**
|
||
|
||
启动 app 登录后:
|
||
- 切换工作空间/项目 → 结构树加载期 **UI 不冻**。
|
||
- 单击对象 → 数据/文件/详情**并发**加载,不冻。
|
||
- 快速连点不同对象 → 旧请求 abort、无错位回灌、无崩溃。
|
||
- 快速改勾选 TM → 异常树以最后一次勾选为准(旧批 abort)。
|
||
- 加载期等待光标(busyChanged)正常出现/消失。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/main.cpp
|
||
git commit -m "feat(app): 装配 WorkbenchNavController 异步导航仓储(引用绑定切换 IAsyncProjectRepository)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A6: cleanup — 删除导航同步接口/方法(若无其他消费者)
|
||
|
||
> 仅当 `ProjectListDialog` 等同步 `IProjectRepository` 消费者已迁移或确认保留时执行。**先 grep 确认消费者**。
|
||
|
||
**Files:**
|
||
- Modify: `src/data/repo/IProjectRepository.hpp`, `src/data/api/ApiProjectRepository.{hpp,cpp}`, `src/app/...`(ProjectListDialog 若迁移)
|
||
|
||
- [ ] **Step 1: 审计同步 `IProjectRepository` 消费者**
|
||
|
||
```bash
|
||
grep -rn "IProjectRepository\|RepoResult\|pageProjects\b\|listProjectTypes\b" src/ tests/
|
||
```
|
||
确认除 `ApiProjectRepository` 自身外的消费者(预期:`ProjectListDialog`、`buildWorkbench` 形参、可能的测试)。
|
||
|
||
- [ ] **Step 2: 决策保留 or 迁移 ProjectListDialog**
|
||
|
||
若 `ProjectListDialog` 是唯一剩余同步消费者:评估迁移到 `pageProjectsAsync`/`listProjectTypesAsync`(它有自己的分页/类型查询 UI)。若改造成本高、收益低(弹窗短时阻塞可接受),**保留同步接口**并在本 Task 仅删「导航控制器已不用的同步方法」中确无消费者的——**KISS:若 ProjectListDialog 仍用同步,则不删 IProjectRepository,本 Task 跳过删除、仅记录技术债。**
|
||
|
||
- [ ] **Step 3(仅当可删): 删同步接口 + 实现 + 继承**
|
||
|
||
`ApiProjectRepository` 去掉 `public IProjectRepository`,删 9 个同步方法实现;删 `IProjectRepository.hpp`(若全无消费者);`buildWorkbench` 形参与 ProjectListDialog 改异步。
|
||
|
||
- [ ] **Step 4: 构建 + 全量测试 + Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor(data): 删除导航同步 IProjectRepository(无消费者) / 或记录 ProjectListDialog 技术债"
|
||
```
|
||
|
||
---
|
||
|
||
# Part B — 登录(高层任务骨架)
|
||
|
||
> 在 Part A 落地、`ApiChain` 经导航/单测验证稳定后展开。登录是**串行依赖链 + 共享会话 + 模态 `exec()`**,风险集中。建议另起 plan `2026-06-11-apiclient-async-login.md` 细化为 bite-sized,或在本文件续写。以下为高层任务骨架与关键设计。
|
||
|
||
## Task B0: net — `AuthService` 异步改形设计确认(无代码,确认信号面)
|
||
|
||
- `fetchCaptcha()` → 异步:返回 `NavRequest*`(payload = `Captcha{codeId,code}` via QVariant,需 `Q_DECLARE_METATYPE(AuthService::Captcha)`)或专用 `CaptchaLoad` 句柄。单请求,用 `NavRequest`/单句柄即可。
|
||
- `login(user,pwd,code,codeId)` → 异步:返回 `LoginLoad*` 句柄(done(token) / failed(msg)),内部用 **`ApiChain`** 编排:step1 = `verifyCodeCheck`(POST,工厂用入参 code/codeId),step2 = `login2`(POST,工厂内先 **RSA 加密**(可抛 → ApiChain 转 failed)再构造 body)。末步 succeeded → 取 `accessToken` → done(token)。
|
||
- **共享会话不变量**:verify 与 login2 经同一 `ApiClient`(同一 NAM),JSESSIONID 串联——`ApiChain` 各 step 工厂调 `api_.postJsonAsync`,与现同步链一致(`ApiClient.hpp:24-27`)。
|
||
- 失败谓词 = `code != 200 || !rawError.isEmpty()`(同 §7,复用)。错误文案:`msg` 空回退 `rawError`,RSA 失败给专门文案(在 login2 工厂 catch 内 throw 带文案的 exception,ApiChain 工厂 catch 写入 rawError)。
|
||
|
||
## Task B1: net — `AuthService` 异步实现 + 离线单测(TDD)
|
||
|
||
- 新句柄 `CaptchaLoad`/`LoginLoad`(或复用 `NavRequest`);`AuthService` 改用 `getAsync/postJsonAsync` + `ApiChain`。
|
||
- 离线单测:用 FakeApiCall 注入 ApiChain(B 需要把 ApiChain 暴露为可注入或测 AuthService 经 stub ApiClient——评估:AuthService 直接持 `ApiClient&`,离线测较难;可加 `IApiClient` 缝或测 ApiChain 层已覆盖链逻辑,AuthService 仅薄编排,留 live 测)。**推荐**:AuthService 薄编排不强求离线单测,链逻辑由 `test_api_chain.cpp` 覆盖,端到端由改造后的 live 测覆盖。
|
||
- 删 `AuthService` 同步 `fetchCaptcha`/`login`(或保留过渡,B3 删)。
|
||
|
||
## Task B2: app — `LoginWindow` 接异步信号(不冻 + 可取消)
|
||
|
||
- `refreshCaptcha()`:调异步 `fetchCaptcha`,连 done → 重绘图、failed → showError;发起期禁用刷新按钮。
|
||
- `attemptLogin()`:调异步 `login`,连 done(token) → `token_=...; accept()`;failed → showError + refreshCaptcha;发起期禁用登录按钮 + 文案「登录中…」(**去掉 `repaint()` 同步 hack**,异步天然不冻)。
|
||
- **取消语义**:窗口关闭/`reject()` 时 abort 在飞 captcha/login 句柄(退出契约);持 `QPointer<...Load>` 成员。
|
||
- 模态 `exec()` 仍可用:异步信号在 `exec()` 的事件循环内正常派发(QDialog::exec 跑嵌套事件循环,句柄 finished 经其泵出)。`accept()` 退出循环。
|
||
|
||
## Task B3: net/app — 删除 `ApiClient` 同步 `get/postJson`(最终清理)
|
||
|
||
- 登录是同步方法最后消费者(导航 Part A 已异步,详情试点已异步)。grep 确认无剩余 `\.get(\|\.postJson(` 同步调用。
|
||
- 删 `ApiClient::get/postJson` + `Impl::await` + `QEventLoop` include。`ApiResponseParse::buildResponse` 仅异步用,保留。
|
||
|
||
## Task B4: tests — `test_auth.cpp` live 改造为异步等待
|
||
|
||
- 现 `test_auth.cpp` 用同步 `fetchCaptcha()`/`login()`。改为:连句柄信号 + `QSignalSpy spy; spy.wait(10000)` 等异步完成;断言 token。仍标记 live(联网)。
|
||
- `QCoreApplication` 事件循环已有(现测试已构造)。
|
||
|
||
## Task B5: app — 手动验证 + 收口
|
||
|
||
- 登录期 UI 不冻、可关窗取消;验证码异步刷新;登录失败异步报错 + 刷新验证码。
|
||
- 全量 dev-test 绿;记忆登录路径(recallValidToken)不受影响(不经登录窗)。
|
||
|
||
---
|
||
|
||
## Self-Review(覆盖核对 + 类型一致性)
|
||
|
||
### 覆盖核对(对照 prompt 规划要点)
|
||
|
||
- **要点 1(登录串行链原语)** → `ApiChain`(Task A0,离线单测 4 例);登录 verify→RSA→login2 用之(Task B0/B1);RSA 在 login2 工厂内同步完成、工厂可抛转 failed;共享会话经同一 ApiClient/NAM(B0 不变量)。✓
|
||
- **要点 2(导航分类)** → 决策 2 表:start/switchWorkspace=依赖链、switchProject/loadMore*/selectDataset=单请求、selectObject/setCheckedTms=可并发。Task A4 分别落地。✓
|
||
- **要点 3(busy_/drain 演化)** → 决策 4:删 busy_/BusyGuard/drain/pending/friend;busyChanged 重定义为「在飞存在性」;setCheckedTms「最后一次为准」由 abort-and-replace 承接(非 TBD)。验证用例 BusyChangedReflectsInflight / SetCheckedTmsAbortsPreviousBatch。✓
|
||
- **要点 4(RepoResult 异步化)** → 决策 3:单非模板 `NavRequest`(QVariant payload)+ `ApiNavRequest`,控制类爆炸;抽象基+Api实现+stub 可测(Task A1 单测)。与详情 ChartLoad/GridLoad 风格差异已说明理由。✓
|
||
- **要点 5(测试)** → 复用 `tests/net/FakeApiCall.hpp`;新增 test_api_chain / test_nav_request / test_workbench_nav_controller(导航控制器原无单测);test_auth.cpp live 改造(Task B4)。✓
|
||
- **要点 6(迁移顺序/可构建性)** → 决策 5 + Task A3 出路 2(Async 后缀,新旧并存,每步绿)+ A4/A5 连续/原子提交标注。✓
|
||
- **要点 7(构建/测试命令)** → header 给出 dev-build/dev-test、LNK1104 处理、dev-test 不构建须先 dev-build、基线 89/89。✓
|
||
- **要点 8(阶段/拆 plan)** → 顶部「阶段划分」:Part A 全 bite-sized、Part B 骨架;推荐拆两份 plan/两 PR;先导航(风险可分散)后登录(风险集中)。✓
|
||
|
||
### 关键修订(实测代码后对初始决策的修正,已在正文标注)
|
||
|
||
- **决策 1 vs Task A4 路线 X:** 导航依赖链最终用「NavRequest 续延」而非 `ApiChain`(导航需业务解析值串联,续延更直观;ApiChain 工厂只透传 ApiResponse)。`ApiChain` 仍在 A0 落地+测试,**专供登录**(登录链透传 response 直接喂下一请求,天然契合)。此修订在 A4 Step 3 块 1 显式记录。
|
||
- **selectObject/setCheckedTms 并发:** 不强行复用 `ApiBatch`(它作用于 IApiCall,而控制器只有 NavRequest),用控制器内「多 NavRequest + remaining 计数」(YAGNI,~15 行/路径);若未来更多并发汇聚点再提炼 `NavBatch`。在 A4 Step 3 块 3 记录判断。
|
||
|
||
### 类型一致性核对
|
||
|
||
- `ApiChain::StepFactory = function<IApiCall*(const QList<ApiResponse>&)>`、`succeeded(QList<ApiResponse>)`/`failed(int,ApiResponse)` — 与 `ApiBatch` 信号面对称。✓
|
||
- `NavRequest::done(QVariant)`/`failed(QString)`、`ApiNavRequest(IApiCall*, Parser=function<QVariant(ApiResponse)>, Predicate)` — 控制器 `qvariant_cast<T>`。✓
|
||
- `IAsyncProjectRepository` 9 方法均返回 `NavRequest*`,名带 `Async` 后缀(与同步 `IProjectRepository` 共存)。✓
|
||
- 各 payload 类型 `Q_DECLARE_METATYPE`(NavLoads.hpp):`vector<Workspace>`/`ProjectListPage`/`vector<ProjectType>`/`vector<StructNode>`/`DsPage`/`DynamicForm`/`vector<ExceptionRow>`;`bool` 内置。同线程直连无需 `qRegisterMetaType`。✓
|
||
- 控制器对外信号面(workspacesLoaded/.../loadFailed/busyChanged)**全程不变**,main.cpp 接线零改动(A5 Step 2)。✓
|
||
- 解析函数名对齐现 `ApiProjectRepository.cpp`:parseWorkspaces/parseProjectPage/parseProjectTypes/parseStructNodes/parseDsPage/parseDynamicForm/parseExceptions。✓
|
||
- 安全不变量(spec §5.0):ApiChain/ApiNavRequest 各持 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 deleteLater。回归用例:ApiChain.AbortGate / NavRequest.AbortSuppressesLateDone / WorkbenchNavController.DropsLateStructureAfterProjectSwitch。✓
|
||
|
||
### 文件 < 800 行 / 函数 < 50 行核对
|
||
|
||
- `ApiChain.cpp`/`NavRequest.cpp` 均 < 60 行。`WorkbenchNavController.cpp` 改后预计 ~250 行(< 800);其最大函数 `setCheckedTms` 异步版需拆 helper(缓存命中组装 / 并发汇聚)保持 < 50 行——在 A4 Step 3 块 5 注明提炼 `assembleExceptionTree()` helper。✓
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
计划已保存 `docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md`。
|
||
|
||
- **Part A(导航)** 全 bite-sized 任务 A0–A6 可直接执行(推荐 subagent-driven,每 Task 一 subagent,A4/A5 连续)。
|
||
- **Part B(登录)** 骨架 B0–B5——执行前先据骨架细化为 bite-sized(或另起 `2026-06-11-apiclient-async-login.md`)。
|
||
|
||
建议:先完整落地 Part A(一个 PR),验证导航全异步收益与 `ApiChain` 稳定,再启动 Part B。
|