# 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`**(抽象基 `INavRequest` + `ApiNavRequest` 实现,控制类爆炸),仓储方法返回 `NavRequest*`(emit `done(T)`/`failed(QString)`),替代同步 `RepoResult`。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& prior)>`(用既往响应构造下一请求,含本地变换如 RSA)。逐步执行:每步 `finished` → `isFailure` 判定(失败则 fail + abort + deleteLater)→ 成功则记录响应、执行下一步;末步成功 → `succeeded(QList)`。`abort()` 置 `aborted_`、abort 当前在飞 call、deleteLater。**入口守卫 `if (aborted_) return;` 同 ApiBatch。** **推荐 (b)。** 与 `ApiBatch` 对称(同 `Predicate`、同信号面 `succeeded(QList)`/`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`(单 ApiCall) | | `selectObject(id,t)` | loadRows(data) + loadRows(file) + loadObjectDetail | **可并发** | `ApiBatch` | | `loadMoreData()` | loadRows(data) | 单请求 | `NavRequest` | | `loadMoreFiles()` | loadRows(file) | 单请求 | `NavRequest` | | `selectDataset(id)` | loadDatasetForm | 单请求 | `NavRequest` | | `setCheckedTms(ids)` | loadExceptionsByTm × N(带缓存,缺失才拉) | **可并发**(仅未命中缓存项) | `ApiBatch` | > 仓储层保持「一方法一请求」的薄封装(返回 `NavRequest*`),**汇聚/链式编排放控制器**(它知道完整序列与状态)——与详情试点把汇聚放 repo 不同,因为导航的序列依赖控制器状态(缓存、当前项目/父节点),放 repo 会泄漏状态。这是导航与详情的**有意差异**,在 self-review 中复核。 ### 决策 3:`RepoResult` 异步化 → 统一泛型句柄 `NavRequest` **问题:** 同步 `RepoResult` 返回值需变成「句柄 emit done(T)/failed(msg)」。导航有 9 个方法、7 种返回类型(`vector`、`bool`、`ProjectListPage`、`vector`、`vector`、`DsPage`、`DynamicForm`、`vector`)。每类型一个具体句柄类 = 类爆炸(且各自 Q_OBJECT/moc)。 **候选:** - (a) 每方法/每类型一句柄(如详情的 `ChartLoad`/`GridLoad`):详情只有 2 个,可接受;导航 7+ 个,爆炸。 - (b) **模板句柄 `NavRequest`(推荐)**:抽象基 `INavRequest`(纯虚 `abort()` + signals `done(T)`/`failed(QString)`)+ `ApiNavRequest`(包一个 `IApiCall`,构造注入 `std::function` 解析器)。**Qt 限制:模板类不能含 `Q_OBJECT`(moc 不支持模板)。** 解决:基类用**非模板** `NavRequestBase : QObject`(Q_OBJECT,signals 用 `QVariant` 承载 payload),模板层 `NavRequest` 继承它、提供 typed `done(T)` 转发;或更简单——**信号 payload 用 `QVariant`,typed 在控制器侧 `value.value()` 取出**。但 `T` 含自定义结构(`DsPage` 等)需 `Q_DECLARE_METATYPE` + 同线程直连无需注册。 - (c) 统一非模板 `NavRequest`,payload 一律 `QVariant`(装任意 `T`)+ 控制器取出:**最省类**,无模板/moc 难题,代价是控制器侧 `.value()` 取值(需对各 `T` 加 `Q_DECLARE_METATYPE`)。 **推荐 (c) 的变体:单个非模板 `NavRequest : QObject`,signals `done(const QVariant&)`/`failed(const QString&)`,`abort()`;具体 `ApiNavRequest` 包 `IApiCall` + `std::function` 解析器。** 理由:① 零模板/零 moc 难题(单类,单次 Q_OBJECT);② 控制器已是「拿到 typed → emit 既有 typed 信号」,多一步 `qvariant_cast` 成本极小;③ 解析器 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`(`std::function& prior)>`,工厂可抛 `std::exception`)+ `Predicate isFailure`;逐步执行、fail-fast、`aborted_` 闸门、一律 deleteLater。signals `succeeded(QList)` / `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` 解析器 + isFailure;finished→判定→解析→done/failed)。 - `src/data/api/NavLoads.hpp` — `Q_DECLARE_METATYPE` 各导航返回类型(`std::vector`、`ProjectListPage`、`std::vector`、`std::vector`、`DsPage`、`DynamicForm`、`std::vector`、`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 #include #include #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 steps{ [&](const QList&) -> IApiCall* { return s1; }, [&](const QList& 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>(); EXPECT_EQ(resps.size(), 2); } TEST(ApiChain, FailFastShortCircuitsRemainingSteps) { auto* s1 = new FakeApiCall; bool secondBuilt = false; QList steps{ [&](const QList&) -> IApiCall* { return s1; }, [&](const QList&) -> 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 steps{[&](const QList&) -> 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 steps{ [&](const QList&) -> IApiCall* { return s1; }, [&](const QList&) -> 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 #include #include #include #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& prior)>; using Predicate = std::function; ApiChain(QList steps, Predicate isFailure, QObject* parent = nullptr); void abort(); signals: void succeeded(const QList& responses); void failed(int index, const geopro::net::ApiResponse& resp); private: void runNext(); // 构造并连接下一步(工厂抛出 → emit failed) QList steps_; Predicate isFailure_; QList responses_; QPointer current_; int index_ = 0; bool aborted_ = false; }; } // namespace geopro::net ``` - [ ] **Step 4: 写 `src/net/ApiChain.cpp`** ```cpp #include "ApiChain.hpp" #include namespace geopro::net { ApiChain::ApiChain(QList 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 #include #include "repo/RepoTypes.hpp" // 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE(无需 qRegisterMetaType)。 Q_DECLARE_METATYPE(std::vector) Q_DECLARE_METATYPE(geopro::data::ProjectListPage) Q_DECLARE_METATYPE(std::vector) Q_DECLARE_METATYPE(std::vector) Q_DECLARE_METATYPE(geopro::data::DsPage) Q_DECLARE_METATYPE(geopro::data::DynamicForm) Q_DECLARE_METATYPE(std::vector) // bool 已内置 QMetaType。 namespace geopro::data { // 控制器并发编排 selectObject 的三响应合成(data+file+detail),仅控制器内部使用。 // (此结构供 self-doc;实际并发由控制器用 ApiBatch + 各 NavRequest 解析器组装,见 Task A5。) } // namespace geopro::data ``` > `switchWorkspace` 返回 `bool` 但需副作用 `setToken`:该副作用放在「链工厂」里(见 A5),`NavRequest` 仅承载成功标志。`switchWorkspace` 解析器需访问 `accessToken` → 由 repo 解析 lambda 内 `api_.setToken(...)`(见 A4 Step 2 注)。 - [ ] **Step 2: 写 `src/data/api/NavRequest.hpp`** ```cpp #pragma once #include #include #include #include #include #include "IApiCall.hpp" namespace geopro::data { // 单请求异步句柄(抽象基,可测试缝):payload 经 QVariant 承载,控制器侧 qvariant_cast 取出。 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; using Predicate = std::function; ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, QObject* parent = nullptr); // 接管 call void abort() override; private: QPointer call_; Parser parse_; Predicate isFailure_; bool aborted_ = false; }; } // namespace geopro::data ``` - [ ] **Step 3: 写 `src/data/api/NavRequest.cpp`** ```cpp #include "api/NavRequest.hpp" #include 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 #include #include #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(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(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 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 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 virtual NavRequest* loadStructure(const std::string& projectId) = 0; // std::vector 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 }; } // 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> 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 #include #include #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{{"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{})); 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{{"w1","WS",2,true}})); repo.lastProjects->fireDone(QVariant::fromValue(data::ProjectListPage{{{"p1","P1"}}, 1})); repo.lastStructure->fireDone(QVariant::fromValue(std::vector{})); 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{})); // 旧 → 丢弃 EXPECT_EQ(stSpy.count(), 0); b->fireDone(QVariant::fromValue(std::vector{})); // 新 → 正常 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 #include #include #include #include #include #include #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& list, const QString& currentId); void projectsLoaded(const std::vector& list, const QString& currentId, int total); void structureLoaded(const QString& projectName, const std::vector& nodes); void datasetsLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); void filesLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); void exceptionTreeLoaded(const std::vector& 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 startChain_; // start / switchWorkspace 依赖链 QPointer structReq_; // switchProject QPointer selectBatch_; // selectObject 三并发 QPointer moreDataReq_; QPointer moreFilesReq_; QPointer datasetReq_; QPointer exceptionsBatch_; // setCheckedTms std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; std::string currentParentId_; int currentParentConfType_ = 0; std::vector lastStructNodes_; std::map> 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 startStepReq_` 跟踪当前级,abort 它即 abort 整条链的当前级)。`aborted_` 闸门由 NavRequest 自身 + 控制器在每级 done 里比对 `sender()/捕获指针 == 当前级` 提供。 - 路线 Y:`ApiChain`(透传 ApiResponse),但导航链需要业务解析后的值(不只是 raw response)构造下一请求——`ApiChain` 工厂入参是 `QList`,可在工厂内重新解析(DTO 解析幂等、廉价),可行但重复解析。 - **采用路线 X**:`ApiChain` 原语**保留供登录用**(登录链透传 response 直接喂下一请求,天然契合);导航依赖链用「NavRequest 续延」在控制器内编排(导航需要业务值串联,续延更直观)。**因此 `startChain_` 成员改为 `QPointer 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 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);任一 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&)>`、`succeeded(QList)`/`failed(int,ApiResponse)` — 与 `ApiBatch` 信号面对称。✓ - `NavRequest::done(QVariant)`/`failed(QString)`、`ApiNavRequest(IApiCall*, Parser=function, Predicate)` — 控制器 `qvariant_cast`。✓ - `IAsyncProjectRepository` 9 方法均返回 `NavRequest*`,名带 `Async` 后缀(与同步 `IProjectRepository` 共存)。✓ - 各 payload 类型 `Q_DECLARE_METATYPE`(NavLoads.hpp):`vector`/`ProjectListPage`/`vector`/`vector`/`DsPage`/`DynamicForm`/`vector`;`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。