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