geopro/docs/superpowers/plans/2026-06-11-apiclient-async-...

1184 lines
74 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ApiClient 异步化 — 全 App 铺开(导航 + 登录)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务执行本计划。每个 Task 内的 Step 用复选框(`- [ ]`)跟踪。每个 bite-sized Step 是一个 25 分钟动作(写失败测试 → 跑 → 实现 → 跑 → 提交)。
**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:** Qt6Core/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 A0A6。** 先行理由:导航虽方法多但每个方法是「值进 → 句柄出」的机械翻译,模式与详情试点最贴近,风险**可控且可分散提交**;其控制器 `busy_`/drain 重构是难点但有详情控制器先例可照搬。`ApiChain` 原语在 Part A 内落地(导航依赖链需要它),同时为登录铺路。
- **Part B — 登录本文件给出高层任务骨架Task B0B5。** 后行理由:登录是**串行依赖链 + 共享会话 + 模态对话框 `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`(同一 NAMcookie/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_OBJECTsignals 用 `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 A4repo 改形)与 Task A5controller 改形)之间标注「**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&)>` 解析器 + isFailurefinished→判定→解析→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`A4A6 删旧。
- `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→login2RSA 在 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-fastfailed(index,resp) + abort 当前在飞 + deleteLater。
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0aborted_ 闸门 + 一律 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 身份比对 + emitappend=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 重放。
- 若全部命中缓存(无在飞请求):同步组装后直接 emitbusyChanged 不抖动)。
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 编译失败,须同批做 A5main.cpp 装配)。**建议 A4 与 A5 连续执行、A5 完成后再要求全绿。**
- [ ] **Step 5: CommitA4**
```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/codeIdstep2 = `login2`POST工厂内先 **RSA 加密**(可抛 → ApiChain 转 failed再构造 body。末步 succeeded → 取 `accessToken` → done(token)。
- **共享会话不变量**verify 与 login2 经同一 `ApiClient`(同一 NAMJSESSIONID 串联——`ApiChain` 各 step 工厂调 `api_.postJsonAsync`,与现同步链一致(`ApiClient.hpp:24-27`)。
- 失败谓词 = `code != 200 || !rawError.isEmpty()`(同 §7复用。错误文案`msg` 空回退 `rawError`RSA 失败给专门文案(在 login2 工厂 catch 内 throw 带文案的 exceptionApiChain 工厂 catch 写入 rawError
## Task B1: net — `AuthService` 异步实现 + 离线单测TDD
- 新句柄 `CaptchaLoad`/`LoginLoad`(或复用 `NavRequest``AuthService` 改用 `getAsync/postJsonAsync` + `ApiChain`
- 离线单测:用 FakeApiCall 注入 ApiChainB 需要把 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/B1RSA 在 login2 工厂内同步完成、工厂可抛转 failed共享会话经同一 ApiClient/NAMB0 不变量)。✓
- **要点 2导航分类** → 决策 2 表start/switchWorkspace=依赖链、switchProject/loadMore*/selectDataset=单请求、selectObject/setCheckedTms=可并发。Task A4 分别落地。✓
- **要点 3busy_/drain 演化)** → 决策 4删 busy_/BusyGuard/drain/pending/friendbusyChanged 重定义为「在飞存在性」setCheckedTms「最后一次为准」由 abort-and-replace 承接(非 TBD。验证用例 BusyChangedReflectsInflight / SetCheckedTmsAbortsPreviousBatch。✓
- **要点 4RepoResult 异步化)** → 决策 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 出路 2Async 后缀,新旧并存,每步绿)+ 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.0ApiChain/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 任务 A0A6 可直接执行推荐 subagent-driven Task subagentA4/A5 连续)。
- **Part B登录** 骨架 B0B5——执行前先据骨架细化为 bite-sized或另起 `2026-06-11-apiclient-async-login.md`)。
建议先完整落地 Part A一个 PR验证导航全异步收益与 `ApiChain` 稳定再启动 Part B