74 KiB
ApiClient 异步化 — 全 App 铺开(导航 + 登录)实现计划
For agentic workers: REQUIRED SUB-SKILL: 用
superpowers:subagent-driven-development(推荐)或superpowers:executing-plans逐任务执行本计划。每个 Task 内的 Step 用复选框(- [ ])跟踪。每个 bite-sized Step 是一个 2–5 分钟动作(写失败测试 → 跑 → 实现 → 跑 → 提交)。
Goal: 把数据集详情试点已验证的「异步句柄 + abort 闸门」模式,铺开到全 App 剩余两条同步阻塞路径——导航(ApiProjectRepository + WorkbenchNavController,9 个仓储方法)与登录(AuthService 串行依赖链 + LoginWindow)。完成后 ApiClient 全程不再用 QEventLoop 阻塞 UI 线程,可移除同步 get/postJson。
Architecture: 复用 net 层已落地原语 IApiCall/ApiCall/ApiBatch(不重造)。新增一个顺序执行原语 ApiChain(依赖链:上一步结果喂下一步 + abort + aborted_ 闸门),供登录与导航的依赖链共用。data 层导航仓储改异步:用统一泛型句柄 NavRequest<T>(抽象基 INavRequest<T> + ApiNavRequest<T> 实现,控制类爆炸),仓储方法返回 NavRequest<T>*(emit done(T)/failed(QString)),替代同步 RepoResult<T>。controller 层 WorkbenchNavController 用 abort-and-replace + 句柄身份比对取代 busy_ 守卫与 drainPendingCheckedTms 重放机制(异步后 QEventLoop 重入消失,重放逻辑自然消亡)。登录 LoginWindow 接 AuthService 异步信号,登录期不冻、可取消。安全靠各层 aborted_ 入口守卫 + 句柄身份比对 + 一律 deleteLater(沿用 spec §5.0)。
Tech Stack: Qt6(Core/Network/Test)、QNetworkAccessManager 原生异步、CMake + Ninja + MSVC、GoogleTest/CTest、QSignalSpy、tests/net/FakeApiCall.hpp(复用)。
权威设计: docs/superpowers/specs/2026-06-11-apiclient-async-design.md(§5.0 安全不变量、§7 错误判定 code==200/退出契约)。试点实现计划范式:docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md。
构建/测试命令:
- 构建:
powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1- 若报
LNK1104 ... geopro_desktop.exe:先Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force再构建。
- 若报
- 全量测试(只跑 ctest,不构建——须先 dev-build):
powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1 - 单测过滤:
build/release/tests/geopro_tests.exe --gtest_filter=ApiChain.* - 基线:当前 89/89 绿。 本计划第一阶段(导航)完成后预期 89 → ~104;第二阶段(登录)完成后预期 ~108。
阶段划分与拆分边界(顶部决策)
本计划范围大(导航 9 方法 + 控制器复杂状态机 + 登录串行链 + 一个新原语),建议分两阶段,并可拆成两份独立 plan 分两个 PR 落地:
- Part A — 导航(本文件给出全部 bite-sized 任务,Task A0–A6)。 先行理由:导航虽方法多但每个方法是「值进 → 句柄出」的机械翻译,模式与详情试点最贴近,风险可控且可分散提交;其控制器
busy_/drain 重构是难点但有详情控制器先例可照搬。ApiChain原语在 Part A 内落地(导航依赖链需要它),同时为登录铺路。 - Part B — 登录(本文件给出高层任务骨架,Task B0–B5)。 后行理由:登录是串行依赖链 + 共享会话 + 模态对话框
exec(),且有联网 live 测试test_auth.cpp需改造,风险点集中(会话 cookie、模态循环里的异步、取消语义),适合在ApiChain经导航验证稳定后再做。Part B 详细 bite-sized 任务在落地 Part A 后,按本骨架另起一份 plan(2026-06-11-apiclient-async-login.md)细化——或直接在本文件继续展开。
拆 plan 的判断: 推荐拆。Part A 一个 PR(导航全异步,含
ApiChain),Part B 一个 PR(登录全异步 + 移除同步get/postJson)。两 PR 之间ApiClient同步方法保留供登录过渡(登录是最后一个同步消费者)。若评审倾向单 PR,则按 Task 顺序 A0→A6→B0→B5 连续执行,每 Task 仍独立提交。
关键设计决策
决策 1:登录串行依赖链 → 新增 ApiChain 原语(而非嵌套 ApiCall 续延)
问题: 登录是 verifyCodeCheck → RSA(本地) → login2,每步用上一步结果(且都走同一 NAM 共享 JSESSIONID)。导航也有依赖链:start = listWorkspaces → pageProjects → loadStructure;switchWorkspace = switch(写新 token) → pageProjects → loadStructure。ApiBatch 是并发汇聚,不适配。
候选:
- (a) 嵌套
ApiCall续延(在每个finishedlambda 里发下一个请求):能用,但 abort/aborted_ 闸门要在每层手写,易漏;多处复制;测试难复用。 - (b)
ApiChain顺序执行原语(推荐):一个 QObject,持有一个「步骤列表」,每步是std::function<IApiCall*(const QList<ApiResponse>& prior)>(用既往响应构造下一请求,含本地变换如 RSA)。逐步执行:每步finished→isFailure判定(失败则 fail + abort + deleteLater)→ 成功则记录响应、执行下一步;末步成功 →succeeded(QList<ApiResponse>)。abort()置aborted_、abort 当前在飞 call、deleteLater。入口守卫if (aborted_) return;同 ApiBatch。
推荐 (b)。 与 ApiBatch 对称(同 Predicate、同信号面 succeeded(QList<ApiResponse>)/failed(int,ApiResponse)、同安全不变量),可离线单测(FakeApiCall 注入),登录与导航依赖链共用。RSA 这类纯本地步骤不发请求:用「步骤工厂返回 nullptr 表示纯本地变换已在工厂内完成,直接进下一步」过于隐晦——改为 RSA 在「构造 login2 请求的工厂 lambda」内同步完成(工厂可抛 std::exception,被 ApiChain 捕获转 failed),无需把本地步骤建模为独立 chain step。
共享会话不变量: ApiChain 的每个 step 工厂调 api_.postJsonAsync/getAsync,全部走同一 ApiClient(同一 NAM),cookie/JSESSIONID 自然串联——与现同步链一致(见 ApiClient.hpp:24-27 注释)。switchWorkspace 链中途 api_.setToken(newToken) 的副作用:必须在「switch 响应到达、构造下一请求之前」执行——ApiChain 的 step 工厂在「构造下一请求时」运行,故把 setToken 放进「pageProjects 请求工厂」的开头(它能读到 switch 的响应),时序正确。
决策 2:导航 9 方法分类(依赖链 / 单请求 / 可并发)
| 控制器入口 | 仓储调用序列 | 类型 | 原语 |
|---|---|---|---|
start() |
listWorkspaces → pageProjects → loadStructure | 依赖链 | ApiChain |
switchWorkspace(id) |
switchWorkspace(setToken) → pageProjects → loadStructure | 依赖链 | ApiChain |
switchProject(id) |
loadStructure | 单请求 | NavRequest<T>(单 ApiCall) |
selectObject(id,t) |
loadRows(data) + loadRows(file) + loadObjectDetail | 可并发 | ApiBatch |
loadMoreData() |
loadRows(data) | 单请求 | NavRequest<T> |
loadMoreFiles() |
loadRows(file) | 单请求 | NavRequest<T> |
selectDataset(id) |
loadDatasetForm | 单请求 | NavRequest<T> |
setCheckedTms(ids) |
loadExceptionsByTm × N(带缓存,缺失才拉) | 可并发(仅未命中缓存项) | ApiBatch |
仓储层保持「一方法一请求」的薄封装(返回
NavRequest<T>*),汇聚/链式编排放控制器(它知道完整序列与状态)——与详情试点把汇聚放 repo 不同,因为导航的序列依赖控制器状态(缓存、当前项目/父节点),放 repo 会泄漏状态。这是导航与详情的有意差异,在 self-review 中复核。
决策 3:RepoResult<T> 异步化 → 统一泛型句柄 NavRequest<T>
问题: 同步 RepoResult<T> 返回值需变成「句柄 emit done(T)/failed(msg)」。导航有 9 个方法、7 种返回类型(vector<Workspace>、bool、ProjectListPage、vector<ProjectType>、vector<StructNode>、DsPage、DynamicForm、vector<ExceptionRow>)。每类型一个具体句柄类 = 类爆炸(且各自 Q_OBJECT/moc)。
候选:
- (a) 每方法/每类型一句柄(如详情的
ChartLoad/GridLoad):详情只有 2 个,可接受;导航 7+ 个,爆炸。 - (b) 模板句柄
NavRequest<T>(推荐):抽象基INavRequest<T>(纯虚abort()+ signalsdone(T)/failed(QString))+ApiNavRequest<T>(包一个IApiCall,构造注入std::function<T(const ApiResponse&)>解析器)。Qt 限制:模板类不能含Q_OBJECT(moc 不支持模板)。 解决:基类用非模板NavRequestBase : QObject(Q_OBJECT,signals 用QVariant承载 payload),模板层NavRequest<T>继承它、提供 typeddone(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 重放,正是为对抗嵌套循环重入。
异步后演化:
- 删除
busy_守卫:异步不阻塞、无嵌套循环、无 slot 重入。 - 删除
checkedTmsPending_/pendingCheckedTms_/drainPendingCheckedTms/BusyGuard/friend struct BusyGuard:重放机制随重入消失而消亡。 - 保留
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 后根据「是否存在任一在飞句柄」发信号(去抖:值变才发)。 - abort-and-replace + 句柄身份比对(取代 busy 拒绝重入):每条路径 abort 旧句柄、存新句柄、
done/failed里比对身份(load != xxxReq_则丢弃迟到信号),与详情控制器一一对应。 setCheckedTms的重入语义:原「busy 时挂起最新一次、空闲重放」→ 异步后「abort 旧异常批、发新批」。用户快速改勾选 = 新请求覆盖旧请求(abort 旧 batch + 身份比对丢旧结果),自然实现「以最后一次勾选为准」,比原挂起-重放更简单且语义更优。缓存tmExceptionCache_保留(命中不发请求)。
难点处理明确化(无 TBD):
drainPendingCheckedTms删除,其「最后一次为准」语义由 abort-and-replace 承接;busy_删除,busyChanged由「在飞句柄存在性」驱动;BusyGuard/friend删除。验证用例见 Task A6 的SetCheckedTmsAbortsPreviousBatch/BusyChangedReflectsInflight。
决策 5:原子落地以保持构建绿(迁移顺序)
接口改形强耦合控制器(详情试点 Task5+6 合并的教训):IProjectRepository(同步 RepoResult)→ IAsyncProjectRepository(句柄)是破坏性改形,ApiProjectRepository 与 WorkbenchNavController 必须同批提交。本计划在 Task A4(repo 改形)与 Task A5(controller 改形)之间标注「A4+A5 必须同一提交或连续提交且中间不要求构建绿」——推荐做法:先在 A3 引入 IAsyncProjectRepository 新接口与 NavRequest(新增、不删旧,构建仍绿、旧路径仍用同步),A4 让 ApiProjectRepository 同时实现新旧两接口(过渡),A5 切换控制器到新接口,A6 删除旧同步接口/方法。这样每步构建绿、可独立提交。
文件结构(每文件职责)
Part A — 导航
新建:
src/net/ApiChain.hpp/src/net/ApiChain.cpp— 顺序执行原语。持步骤工厂列表QList<StepFactory>(std::function<IApiCall*(const QList<ApiResponse>& prior)>,工厂可抛std::exception)+Predicate isFailure;逐步执行、fail-fast、aborted_闸门、一律 deleteLater。signalssucceeded(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(),signalsdone(QVariant)/failed(QString))+ApiNavRequest(包IApiCall+std::function<QVariant(const ApiResponse&)>解析器 + isFailure;finished→判定→解析→done/failed)。src/data/api/NavLoads.hpp—Q_DECLARE_METATYPE各导航返回类型(std::vector<Workspace>、ProjectListPage、std::vector<ProjectType>、std::vector<StructNode>、DsPage、DynamicForm、std::vector<ExceptionRow>、bool已内置);并定义控制器编排用的合成结果载体(见下)。src/data/repo/IAsyncProjectRepository.hpp— 异步导航仓储抽象:每方法返回NavRequest*(薄封装,一方法一请求)。9 方法对齐现IProjectRepository。tests/data/test_nav_request.cpp—ApiNavRequest离线单测(FakeApiCall + 桩解析器:done/failed/abort 闸门)。tests/controller/test_workbench_nav_controller.cpp— 控制器离线单测(Stub 异步 repo + QSignalSpy):覆盖依赖链、并发、abort-and-replace、busyChanged、setCheckedTms 覆盖语义、回灌防护。当前不存在(导航控制器现无单测)。
修改:
src/net/CMakeLists.txt— 源列表加ApiChain.cpp(AUTOMOC 已 ON)。src/data/api/ApiProjectRepository.hpp/.cpp— 改/扩展实现IAsyncProjectRepository(每方法用getAsync/postJsonAsync+ApiNavRequest+ DTO 解析 lambda)。过渡期同时保留IProjectRepository(A4),A6 删旧。src/data/CMakeLists.txt— 源列表加NavRequest.cpp(AUTOMOC 已 ON)。src/controller/WorkbenchNavController.hpp/.cpp— 依赖IAsyncProjectRepository;删busy_/BusyGuard/drain/pending;改ApiChain/ApiBatch/NavRequest编排 + abort-and-replace + 身份比对 + busyChanged 重定义。tests/CMakeLists.txt— 加 test_api_chain / test_nav_request / test_workbench_nav_controller。src/app/main.cpp— 装配换IAsyncProjectRepository(引用绑定,接线信号面不变);ProjectListDialog形参类型核对(见 A5 Step)。
Part B — 登录(骨架)
新建:
src/net/AuthService改异步(见下);tests/net/test_auth_async.cpp(可选 live + 离线 stub 段)。
修改:
src/net/AuthService.hpp/.cpp—fetchCaptcha/login改异步:返回句柄或经回调/信号。用ApiChain编排 verify→login2(RSA 在 login2 工厂内)。src/app/login/LoginWindow.hpp/.cpp— 接异步信号;登录期禁用按钮/显示「登录中」、不冻;可取消(关窗 abort)。tests/net/test_auth.cpp— live 测试改造为异步等待(QSignalSpy::wait)。src/app/main.cpp— 移除同步ApiClient::get/postJson消费后,删除同步方法(最终清理)。
不动: src/data/repo/IDatasetRepository.hpp、详情路径(已异步)、LocalSampleRepository、src/net/crypto/RsaEncryptor.*。
Part A — 导航
Task A0: net — ApiChain 顺序执行原语 + 离线单测(TDD)
复用 IApiCall/ApiResponse,与 ApiBatch 对称。新增不破坏现有。
Files:
-
Create:
src/net/ApiChain.hpp,src/net/ApiChain.cpp,tests/net/test_api_chain.cpp -
Modify:
src/net/CMakeLists.txt,tests/CMakeLists.txt -
Step 1: 写失败测试
tests/net/test_api_chain.cpp(复用tests/net/FakeApiCall.hpp)
#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-fast:failed(index,resp) + abort 当前在飞 + deleteLater。
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
class ApiChain : public QObject {
Q_OBJECT
public:
// 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
using Predicate = std::function<bool(const ApiResponse&)>;
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);
void abort();
signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses);
void failed(int index, const geopro::net::ApiResponse& resp);
private:
void runNext(); // 构造并连接下一步(工厂抛出 → emit failed)
QList<StepFactory> steps_;
Predicate isFailure_;
QList<ApiResponse> responses_;
QPointer<IApiCall> current_;
int index_ = 0;
bool aborted_ = false;
};
} // namespace geopro::net
- Step 4: 写
src/net/ApiChain.cpp
#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(无需 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
#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)(上方草稿的取值写法笨拙,落地时简化为这一行;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 后)加:
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_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
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 把接口方法名改为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声明(保留全部同步方法不动)
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.cpp(Stub 异步 repo + QSignalSpy)
桩策略:定义一个 StubNavRequest(不声明 Q_OBJECT,发射继承自 NavRequest 的 done/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.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 语义重定义为「有在飞」)。
#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」):
-
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(推荐,KISS):不用
- 采用路线 X:
ApiChain原语保留供登录用(登录链透传 response 直接喂下一请求,天然契合);导航依赖链用「NavRequest 续延」在控制器内编排(导航需要业务值串联,续延更直观)。因此startChain_成员改为QPointer<data::NavRequest> startStepReq_(跟踪 start/switchWorkspace 链当前在飞级)。
决策修订记录(self-review 复核):决策 1 说「导航依赖链用 ApiChain」,实测代码后修订为「导航依赖链用 NavRequest 续延(因需业务值串联)、ApiChain 专供登录(透传 response 串联)」。
ApiChain仍在 A0 落地并测试,Part B 登录使用。 -
switchProject()用单NavRequest(loadStructureAsync): abort 旧structReq_;存新;done lambda 内身份比对 + 1:1 搬现状态更新(currentProjectId_/currentProjectName_/currentCrsCode_fromlastProjects_、tmExceptionCache_.clear()、重置选中态)+ emitstructureLoaded。 -
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后 emitdatasetsLoaded+filesLoaded+objectDetailLoaded。失败任一 → emitloadFailed+ abort 其余。
这复刻了
ApiBatch的能力但作用于NavRequest——是否值得为此再造?判断:ApiBatch作用于IApiCall,selectObject需要的是「三个 typed NavRequest 的汇聚」。可以加一个轻量NavBatch(汇聚 N 个NavRequest,全 done → succeeded(QList);任一 failed → fail + abort)。但 selectObject 是唯一的导航并发汇聚点(setCheckedTms 见下,其实是「N 个相同类型请求 + 缓存」),YAGNI 下先用控制器内计数(~15 行);若 setCheckedTms 也要并发汇聚,则提炼NavBatch。本计划:selectObject 用控制器内三句柄计数(不新增原语)。 - abort 旧
-
loadMoreData()/loadMoreFiles()/selectDataset()用单NavRequest: abort 旧、存新、done 身份比对 + emit(append=true / datasetDetailLoaded)。删currentParentId_.empty()之外的 busy 守卫。 -
setCheckedTms()异步化(去 busy/pending/drain):- abort 旧
exceptionsBatch_(或续延句柄)。 - 命中缓存的 TM 直接组装;未命中的 N 个
loadExceptionsByTmAsync并发、计数汇聚(同 selectObject 计数模式),全到齐后写缓存 +groupExceptionsByConsortium组装 + emitexceptionTreeLoaded。 - 以最后一次勾选为准:新
setCheckedTmsabort 旧的在飞集(abort-and-replace),无需 pending 重放。 - 若全部命中缓存(无在飞请求):同步组装后直接 emit(busyChanged 不抖动)。
- abort 旧
-
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.cppinclude + 注册测试 + 跑
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——但navctor 已改IAsyncProjectRepository&。Step 4 跑测试前不构建 app 也可(dev-build 构建全部);若 app 编译失败,须同批做 A5(main.cpp 装配)。建议 A4 与 A5 连续执行、A5 完成后再要求全绿。
- Step 5: Commit(A4)
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
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 自身外的消费者(预期: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
git add -A
git commit -m "refactor(data): 删除导航同步 IProjectRepository(无消费者) / 或记录 ProjectListDialog 技术债"
Part B — 登录(高层任务骨架)
在 Part A 落地、
ApiChain经导航/单测验证稳定后展开。登录是串行依赖链 + 共享会话 + 模态exec(),风险集中。建议另起 plan2026-06-11-apiclient-async-login.md细化为 bite-sized,或在本文件续写。以下为高层任务骨架与关键设计。
Task B0: net — AuthService 异步改形设计确认(无代码,确认信号面)
fetchCaptcha()→ 异步:返回NavRequest*(payload =Captcha{codeId,code}via QVariant,需Q_DECLARE_METATYPE(AuthService::Captcha))或专用CaptchaLoad句柄。单请求,用NavRequest/单句柄即可。login(user,pwd,code,codeId)→ 异步:返回LoginLoad*句柄(done(token) / failed(msg)),内部用ApiChain编排:step1 =verifyCodeCheck(POST,工厂用入参 code/codeId),step2 =login2(POST,工厂内先 RSA 加密(可抛 → ApiChain 转 failed)再构造 body)。末步 succeeded → 取accessToken→ done(token)。- 共享会话不变量:verify 与 login2 经同一
ApiClient(同一 NAM),JSESSIONID 串联——ApiChain各 step 工厂调api_.postJsonAsync,与现同步链一致(ApiClient.hpp:24-27)。 - 失败谓词 =
code != 200 || !rawError.isEmpty()(同 §7,复用)。错误文案:msg空回退rawError,RSA 失败给专门文案(在 login2 工厂 catch 内 throw 带文案的 exception,ApiChain 工厂 catch 写入 rawError)。
Task B1: net — AuthService 异步实现 + 离线单测(TDD)
- 新句柄
CaptchaLoad/LoginLoad(或复用NavRequest);AuthService改用getAsync/postJsonAsync+ApiChain。 - 离线单测:用 FakeApiCall 注入 ApiChain(B 需要把 ApiChain 暴露为可注入或测 AuthService 经 stub ApiClient——评估:AuthService 直接持
ApiClient&,离线测较难;可加IApiClient缝或测 ApiChain 层已覆盖链逻辑,AuthService 仅薄编排,留 live 测)。推荐:AuthService 薄编排不强求离线单测,链逻辑由test_api_chain.cpp覆盖,端到端由改造后的 live 测覆盖。 - 删
AuthService同步fetchCaptcha/login(或保留过渡,B3 删)。
Task B2: app — LoginWindow 接异步信号(不冻 + 可取消)
refreshCaptcha():调异步fetchCaptcha,连 done → 重绘图、failed → showError;发起期禁用刷新按钮。attemptLogin():调异步login,连 done(token) →token_=...; accept();failed → showError + refreshCaptcha;发起期禁用登录按钮 + 文案「登录中…」(去掉repaint()同步 hack,异步天然不冻)。- 取消语义:窗口关闭/
reject()时 abort 在飞 captcha/login 句柄(退出契约);持QPointer<...Load>成员。 - 模态
exec()仍可用:异步信号在exec()的事件循环内正常派发(QDialog::exec 跑嵌套事件循环,句柄 finished 经其泵出)。accept()退出循环。
Task B3: net/app — 删除 ApiClient 同步 get/postJson(最终清理)
- 登录是同步方法最后消费者(导航 Part A 已异步,详情试点已异步)。grep 确认无剩余
\.get(\|\.postJson(同步调用。 - 删
ApiClient::get/postJson+Impl::await+QEventLoopinclude。ApiResponseParse::buildResponse仅异步用,保留。
Task B4: tests — test_auth.cpp live 改造为异步等待
- 现
test_auth.cpp用同步fetchCaptcha()/login()。改为:连句柄信号 +QSignalSpy spy; spy.wait(10000)等异步完成;断言 token。仍标记 live(联网)。 QCoreApplication事件循环已有(现测试已构造)。
Task B5: app — 手动验证 + 收口
- 登录期 UI 不冻、可关窗取消;验证码异步刷新;登录失败异步报错 + 刷新验证码。
- 全量 dev-test 绿;记忆登录路径(recallValidToken)不受影响(不经登录窗)。
Self-Review(覆盖核对 + 类型一致性)
覆盖核对(对照 prompt 规划要点)
- 要点 1(登录串行链原语) →
ApiChain(Task A0,离线单测 4 例);登录 verify→RSA→login2 用之(Task B0/B1);RSA 在 login2 工厂内同步完成、工厂可抛转 failed;共享会话经同一 ApiClient/NAM(B0 不变量)。✓ - 要点 2(导航分类) → 决策 2 表:start/switchWorkspace=依赖链、switchProject/loadMore*/selectDataset=单请求、selectObject/setCheckedTms=可并发。Task A4 分别落地。✓
- 要点 3(busy_/drain 演化) → 决策 4:删 busy_/BusyGuard/drain/pending/friend;busyChanged 重定义为「在飞存在性」;setCheckedTms「最后一次为准」由 abort-and-replace 承接(非 TBD)。验证用例 BusyChangedReflectsInflight / SetCheckedTmsAbortsPreviousBatch。✓
- 要点 4(RepoResult 异步化) → 决策 3:单非模板
NavRequest(QVariant payload)+ApiNavRequest,控制类爆炸;抽象基+Api实现+stub 可测(Task A1 单测)。与详情 ChartLoad/GridLoad 风格差异已说明理由。✓ - 要点 5(测试) → 复用
tests/net/FakeApiCall.hpp;新增 test_api_chain / test_nav_request / test_workbench_nav_controller(导航控制器原无单测);test_auth.cpp live 改造(Task B4)。✓ - 要点 6(迁移顺序/可构建性) → 决策 5 + Task A3 出路 2(Async 后缀,新旧并存,每步绿)+ A4/A5 连续/原子提交标注。✓
- 要点 7(构建/测试命令) → header 给出 dev-build/dev-test、LNK1104 处理、dev-test 不构建须先 dev-build、基线 89/89。✓
- 要点 8(阶段/拆 plan) → 顶部「阶段划分」:Part A 全 bite-sized、Part B 骨架;推荐拆两份 plan/两 PR;先导航(风险可分散)后登录(风险集中)。✓
关键修订(实测代码后对初始决策的修正,已在正文标注)
- 决策 1 vs Task A4 路线 X: 导航依赖链最终用「NavRequest 续延」而非
ApiChain(导航需业务解析值串联,续延更直观;ApiChain 工厂只透传 ApiResponse)。ApiChain仍在 A0 落地+测试,专供登录(登录链透传 response 直接喂下一请求,天然契合)。此修订在 A4 Step 3 块 1 显式记录。 - selectObject/setCheckedTms 并发: 不强行复用
ApiBatch(它作用于 IApiCall,而控制器只有 NavRequest),用控制器内「多 NavRequest + remaining 计数」(YAGNI,~15 行/路径);若未来更多并发汇聚点再提炼NavBatch。在 A4 Step 3 块 3 记录判断。
类型一致性核对
ApiChain::StepFactory = function<IApiCall*(const QList<ApiResponse>&)>、succeeded(QList<ApiResponse>)/failed(int,ApiResponse)— 与ApiBatch信号面对称。✓NavRequest::done(QVariant)/failed(QString)、ApiNavRequest(IApiCall*, Parser=function<QVariant(ApiResponse)>, Predicate)— 控制器qvariant_cast<T>。✓IAsyncProjectRepository9 方法均返回NavRequest*,名带Async后缀(与同步IProjectRepository共存)。✓- 各 payload 类型
Q_DECLARE_METATYPE(NavLoads.hpp):vector<Workspace>/ProjectListPage/vector<ProjectType>/vector<StructNode>/DsPage/DynamicForm/vector<ExceptionRow>;bool内置。同线程直连无需qRegisterMetaType。✓ - 控制器对外信号面(workspacesLoaded/.../loadFailed/busyChanged)全程不变,main.cpp 接线零改动(A5 Step 2)。✓
- 解析函数名对齐现
ApiProjectRepository.cpp:parseWorkspaces/parseProjectPage/parseProjectTypes/parseStructNodes/parseDsPage/parseDynamicForm/parseExceptions。✓ - 安全不变量(spec §5.0):ApiChain/ApiNavRequest 各持
aborted_入口守卫 + 控制器句柄身份比对 + 一律 deleteLater。回归用例:ApiChain.AbortGate / NavRequest.AbortSuppressesLateDone / WorkbenchNavController.DropsLateStructureAfterProjectSwitch。✓
文件 < 800 行 / 函数 < 50 行核对
ApiChain.cpp/NavRequest.cpp均 < 60 行。WorkbenchNavController.cpp改后预计 ~250 行(< 800);其最大函数setCheckedTms异步版需拆 helper(缓存命中组装 / 并发汇聚)保持 < 50 行——在 A4 Step 3 块 5 注明提炼assembleExceptionTree()helper。✓
Execution Handoff
计划已保存 docs/superpowers/plans/2026-06-11-apiclient-async-rollout.md。
- Part A(导航) 全 bite-sized 任务 A0–A6 可直接执行(推荐 subagent-driven,每 Task 一 subagent,A4/A5 连续)。
- Part B(登录) 骨架 B0–B5——执行前先据骨架细化为 bite-sized(或另起
2026-06-11-apiclient-async-login.md)。
建议:先完整落地 Part A(一个 PR),验证导航全异步收益与 ApiChain 稳定,再启动 Part B。