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

74 KiB
Raw Blame History

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 + WorkbenchNavController9 个仓储方法)与登录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 重入消失,重放逻辑自然消亡)。登录 LoginWindowAuthService 异步信号,登录期不冻、可取消。安全靠各层 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-buildpowershell.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 后,按本骨架另起一份 plan2026-06-11-apiclient-async-login.md)细化——或直接在本文件继续展开。

拆 plan 的判断: 推荐拆。Part A 一个 PR导航全异步ApiChainPart B 一个 PR登录全异步 + 移除同步 get/postJson)。两 PR 之间 ApiClient 同步方法保留供登录过渡(登录是最后一个同步消费者)。若评审倾向单 PR则按 Task 顺序 A0→A6→B0→B5 连续执行,每 Task 仍独立提交。


关键设计决策

决策 1登录串行依赖链 → 新增 ApiChain 原语(而非嵌套 ApiCall 续延)

问题: 登录是 verifyCodeCheck → RSA(本地) → login2,每步用上一步结果(且都走同一 NAM 共享 JSESSIONID。导航也有依赖链start = listWorkspaces → pageProjects → loadStructureswitchWorkspace = switch(写新 token) → pageProjects → loadStructure。ApiBatch并发汇聚,不适配。

候选:

  • (a) 嵌套 ApiCall 续延(在每个 finished lambda 里发下一个请求):能用,但 abort/aborted_ 闸门要在每层手写,易漏;多处复制;测试难复用。
  • (b) ApiChain 顺序执行原语(推荐):一个 QObject持有一个「步骤列表」每步是 std::function<IApiCall*(const QList<ApiResponse>& prior)>(用既往响应构造下一请求,含本地变换如 RSA。逐步执行每步 finishedisFailure 判定(失败则 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 中复核。

决策 3RepoResult<T> 异步化 → 统一泛型句柄 NavRequest<T>

问题: 同步 RepoResult<T> 返回值需变成「句柄 emit done(T)/failed(msg)」。导航有 9 个方法、7 种返回类型(vector<Workspace>boolProjectListPagevector<ProjectType>vector<StructNode>DsPageDynamicFormvector<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_OBJECTmoc 不支持模板)。 解决:基类用非模板 NavRequestBase : QObjectQ_OBJECTsignals 用 QVariant 承载 payload模板层 NavRequest<T> 继承它、提供 typed done(T) 转发;或更简单——信号 payload 用 QVarianttyped 在控制器侧 value.value<T>() 取出。但 T 含自定义结构(DsPage 等)需 Q_DECLARE_METATYPE + 同线程直连无需注册。
  • (c) 统一非模板 NavRequestpayload 一律 QVariant(装任意 T+ 控制器取出:最省类,无模板/moc 难题,代价是控制器侧 .value<DsPage>() 取值(需对各 TQ_DECLARE_METATYPE)。

推荐 (c) 的变体:单个非模板 NavRequest : QObjectsignals done(const QVariant&)/failed(const QString&)abort();具体 ApiNavRequestIApiCall + std::function<QVariant(const ApiResponse&)> 解析器。 理由:① 零模板/零 moc 难题(单类,单次 Q_OBJECT② 控制器已是「拿到 typed → emit 既有 typed 信号」,多一步 qvariant_cast<DsPage> 成本极小;③ 解析器 lambda 在 repo 内捕获 DTO 解析(与现 dto::parseXxx 一对一);④ abort/aborted_ 闸门只在一处实现。对各返回类型加 Q_DECLARE_METATYPEDsPage/DynamicForm/ProjectListPage/vector<...> 等,同线程直连下声明即可、无需 qRegisterMetaType)。

与详情试点的 ChartLoad/GridLoadtyped 信号)风格略不同,但详情只 2 类、且 Parts 是合成结构;导航 7 类用 typed-per-class 不划算。NavRequest(QVariant) 是导航规模下的正确权衡,在 self-review 复核类型一致性。

决策 4WorkbenchNavControllerbusy_ / drainPendingCheckedTms 演化(难点)

现状根因: busy_ 守卫 + BusyGuard RAII 配平 busyChanged + drainPendingCheckedTms 重放,全部是**同步 QEventLoop 阻塞下「Qt 仍泵事件 → slot 重入」**的产物spec §1setCheckedTmsbusy_ 时把请求挂起、待 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(句柄)是破坏性改形ApiProjectRepositoryWorkbenchNavController 必须同批提交。本计划在 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.cppNavRequest : QObject(抽象基:纯虚 abort()signals done(QVariant)/failed(QString)+ ApiNavRequest(包 IApiCall + std::function<QVariant(const ApiResponse&)> 解析器 + isFailurefinished→判定→解析→done/failed
  • src/data/api/NavLoads.hppQ_DECLARE_METATYPE 各导航返回类型(std::vector<Workspace>ProjectListPagestd::vector<ProjectType>std::vector<StructNode>DsPageDynamicFormstd::vector<ExceptionRow>bool 已内置);并定义控制器编排用的合成结果载体(见下)。
  • src/data/repo/IAsyncProjectRepository.hpp — 异步导航仓储抽象:每方法返回 NavRequest*薄封装一方法一请求。9 方法对齐现 IProjectRepository
  • tests/data/test_nav_request.cppApiNavRequest 离线单测FakeApiCall + 桩解析器done/failed/abort 闸门)。
  • tests/controller/test_workbench_nav_controller.cpp — 控制器离线单测Stub 异步 repo + QSignalSpy覆盖依赖链、并发、abort-and-replace、busyChanged、setCheckedTms 覆盖语义、回灌防护。当前不存在(导航控制器现无单测)。

修改:

  • src/net/CMakeLists.txt — 源列表加 ApiChain.cppAUTOMOC 已 ON
  • src/data/api/ApiProjectRepository.hpp / .cpp — 改/扩展实现 IAsyncProjectRepository(每方法用 getAsync/postJsonAsync + ApiNavRequest + DTO 解析 lambda。过渡期同时保留 IProjectRepositoryA4A6 删旧。
  • src/data/CMakeLists.txt — 源列表加 NavRequest.cppAUTOMOC 已 ON
  • src/controller/WorkbenchNavController.hpp / .cpp — 依赖 IAsyncProjectRepository;删 busy_/BusyGuard/drain/pendingApiChain/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 / .cppfetchCaptcha/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、详情路径(已异步)、LocalSampleRepositorysrc/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

#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 之后)加:

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
#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
#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
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(元类型声明 + 编排合成载体)

#pragma once
#include <vector>
#include <QMetaType>
#include "repo/RepoTypes.hpp"

// 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE无需 qRegisterMetaTypeQ_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:该副作用放在「链工厂」里(见 A5NavRequest<bool> 仅承载成功标志。switchWorkspace 解析器需访问 accessToken → 由 repo 解析 lambda 内 api_.setToken(...)(见 A4 Step 2 注)。

  • Step 2: 写 src/data/api/NavRequest.hpp
#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
#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
#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)(上方草稿的取值写法笨拙,落地时简化为这一行;DsPageQ_DECLARE_METATYPE)。

  • Step 5: 注册 data 源 + 测试到 CMake

src/data/CMakeLists.txt 源列表加 api/NavRequest.cpp(在 api/DatasetLoadHandles.cppAUTOMOC 已 ONtests/CMakeLists.txt data 段(data/test_dataset_load_handles.cpp 后)加:

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
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

#pragma once
#include <string>

namespace geopro::data {

class NavRequest;

// 导航异步仓储抽象(薄封装:一方法一请求,返回自管理句柄 emit done(QVariant)/failed(msg))。
// 汇聚/链式编排由 WorkbenchNavController 负责(它知道完整序列与状态)。
// 方法与同步 IProjectRepository 一一对应payload 类型见各方法注释(控制器 qvariant_castclass 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
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 个异步方法声明

#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 把接口方法名改为 listWorkspacesAsyncA2 已提交则在本 Task 一并修正 + 重新提交 A2 头)。

  • Step 1b: 修正 IAsyncProjectRepository.hpp 方法名加 Async 后缀

把 A2 的 9 个方法改名:listWorkspacesAsync / switchWorkspaceAsync / pageProjectsAsync / listProjectTypesAsync / loadStructureAsync / loadRowsAsync / loadObjectDetailAsync / loadDatasetFormAsync / loadExceptionsByTmAsync(参数同前)。

  • Step 2: ApiProjectRepository.hpp 加 9 个 ...Async 声明(保留全部同步方法不动)
    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 加:

#include "ApiBatch.hpp"            // 仅若需要;本 repo 薄封装不直接用 batch控制器用
#include "api/NavRequest.hpp"
#include "api/NavLoads.hpp"

匿名命名空间内已有 enc/ok/errorOf;新增异步失败谓词(与同步 ok 同口径):

bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }

在文件末尾(同步方法之后)加 9 个异步实现,例如:

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
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.cppStub 异步 repo + QSignalSpy

桩策略:定义一个 StubNavRequest(不声明 Q_OBJECT发射继承自 NavRequestdone/failed、override abort 记录),StubAsyncRepo 实现 IAsyncProjectRepository,每方法 new StubNavRequest 并记录最近一个(按方法名分桶),测试可手动 fireDone(QVariant)/fireFailed()/查 aborted

#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.hppid,name,ownerType,isCurrentProjectSummary/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/ApiBatchQPointer;④ 加 emitBusyIfChanged() 私有辅助 + bool lastBusy_;⑤ 信号面不变(busyChanged 语义重定义为「有在飞」)。

#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()/捕获指针 == 当前级 提供。
      • 路线 YApiChain(透传 ApiResponse但导航链需要业务解析后的值不只是 raw response构造下一请求——ApiChain 工厂入参是 QList<ApiResponse>可在工厂内重新解析DTO 解析幂等、廉价),可行但重复解析。
    • 采用路线 XApiChain 原语保留供登录用(登录链透传 response 直接喂下一请求天然契合导航依赖链用「NavRequest 续延」在控制器内编排(导航需要业务值串联,续延更直观)。因此 startChain_ 成员改为 QPointer<data::NavRequest> startStepReq_(跟踪 start/switchWorkspace 链当前在飞级)。

    决策修订记录self-review 复核):决策 1 说「导航依赖链用 ApiChain」实测代码后修订为「导航依赖链用 NavRequest 续延因需业务值串联、ApiChain 专供登录(透传 response 串联)」。ApiChain 仍在 A0 落地并测试Part B 登录使用。

  2. switchProject() 用单 NavRequestloadStructureAsync 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 作用于 IApiCallselectObject 需要的是「三个 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 身份比对 + 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()

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 改为:

#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 后)加:

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
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-859ApiProjectRepository projectRepo(api); WorkbenchNavController nav(projectRepo);——projectRepo 现同时是 IProjectRepositoryIAsyncProjectRepositorynav 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

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 消费者

grep -rn "IProjectRepository\|RepoResult\|pageProjects\b\|listProjectTypes\b" src/ tests/

确认除 ApiProjectRepository 自身外的消费者(预期:ProjectListDialogbuildWorkbench 形参、可能的测试)。

  • 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
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 QVariantQ_DECLARE_METATYPE(AuthService::Captcha))或专用 CaptchaLoad 句柄。单请求,用 NavRequest/单句柄即可。
  • login(user,pwd,code,codeId) → 异步:返回 LoginLoad* 句柄done(token) / failed(msg)),内部用 ApiChain 编排step1 = verifyCodeCheckPOST工厂用入参 code/codeIdstep2 = login2POST工厂内先 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 空回退 rawErrorRSA 失败给专门文案(在 login2 工厂 catch 内 throw 带文案的 exceptionApiChain 工厂 catch 写入 rawError

Task B1: net — AuthService 异步实现 + 离线单测TDD

  • 新句柄 CaptchaLoad/LoginLoad(或复用 NavRequestAuthService 改用 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登录串行链原语ApiChainTask 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单非模板 NavRequestQVariant 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 工厂只透传 ApiResponseApiChain 仍在 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_METATYPENavLoads.hppvector<Workspace>/ProjectListPage/vector<ProjectType>/vector<StructNode>/DsPage/DynamicForm/vector<ExceptionRow>bool 内置。同线程直连无需 qRegisterMetaType。✓
  • 控制器对外信号面workspacesLoaded/.../loadFailed/busyChanged全程不变main.cpp 接线零改动A5 Step 2。✓
  • 解析函数名对齐现 ApiProjectRepository.cppparseWorkspaces/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。