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

25 KiB
Raw Blame History

设计ApiClient 异步化DatasetDetail 路径试点)

日期 2026-06-11。范围把数据集详情路径从同步阻塞改为异步非阻塞作为全 App 异步化的模式样板。 后续会按此模式铺开到导航/登录路径(本期不做)。

状态更新2026-06-11

DatasetDetail 试点: 已完成并通过评审。 实现计划 plans/2026-06-11-apiclient-async-datasetdetail.md8 任务,逐任务 spec+质量双评审 + 整体评审)。测试 75 → 89+14 离线用例)全绿。落地原语:IApiCall/ApiCall/ApiBatchnetChartLoad/GridLoad/IAsyncDatasetRepositorydata、控制器 abort-and-replace + 句柄身份比对 + 退出契约、LoadingOverlay 网格懒加载遮罩。核心收益落地:详情路径不冻 UI、慢请求可 abort 不回灌、多请求并发 + fail-fast。

铺开进展2026-06-12 更新):

  • 导航路径 (计划 plans/2026-06-11-apiclient-async-rollout.md Part A新增 ApiChain(串行依赖链原语)、NavRequest单请求句柄QVariant payloadIAsyncProjectRepositoryWorkbenchNavController 全异步NavRequest 续延 + 并发计数 + abort-and-replace + 身份比对,删 busy_/drainbusyChanged=在飞存在性)。
  • 登录路径 (同计划 Part BB1/B2/B4AuthService 异步(CaptchaLoad/LoginLoad + ApiChain 编排 verify→RSA→login2LoginWindow 不冻 + 可取消(析构 aborttest_auth live 异步化。
  • 测试 89 → 116。每块逐任务 spec+质量双评审 + 整体评审通过。

仍未完成BLOCKED同根ProjectListDialog 仍同步消费 IProjectRepository

  • 删除 ApiClient 同步 get/postJsonPart B 的 B3+ 删除同步 IProjectRepositoryPart A 的 A6。解锁前置先把 ProjectListDialog 迁移到 IAsyncProjectRepository
  • ApiProjectRepository 暂同时实现同步+异步两接口(过渡技术债)。

可选 follow-up评审建议非阻断DatasetDetailController::ChartData.grid/gridScale 死字段;补 qRegisterMetaType<QList<ApiResponse>>()

铺开实现计划:plans/2026-06-11-apiclient-async-rollout.mdPart A/B 已落地B3 BLOCKED

1. 背景

geopro 现网络栈三层全同步阻塞:

  • ApiClientsrc/net/):单个 QNetworkAccessManagerget/postJson 内部用 QNetworkReply + QEventLoop 死等响应。共享 cookie / JSESSIONID。
  • RepositoryApiDatasetRepositoryApiProjectRepository):同步调 api_.get/postJson,解析后返回 typed 值或抛 std::runtime_error
  • ControllerDatasetDetailControllerWorkbenchNavController):在 slot 内同步调 repoemit 结果信号。

核心问题:每次请求都用 QEventLoop 阻塞 UI 线程 → 全 App 冻结。其中数据集详情「网格数据」页签的 dd/ert/inversion/rows/{id} 请求因服务端网格化波动 14s,期间界面完全卡死。

QEventLoop 阻塞的副作用是可重入:阻塞期间 Qt 仍泵事件,用户的双击/切换会重入 slot因此现有控制器到处用 busy_ 守卫挡重入——这些守卫本身就是同步阻塞架构的产物。

QNetworkAccessManager 本就是异步设计;当前架构用 QEventLoop 把它人为变同步,是反模式。本设计回归 Qt 原生异步。

2. 目标与非目标

目标

  1. 数据集详情路径(原数据 + 网格数据)全程不阻塞 UI 线程
  2. 慢请求在飞时切换/关闭数据集页签可主动 abort 过期请求,结果不回灌。
  3. 同一逻辑加载内的多个独立请求并发发出(原数据 2 个、网格 3 个),缩短总等待。
  4. 加载期间给用户轻量"加载中"反馈(不再因冻结而"看起来在响应")。
  5. 产出可复用的异步原语ApiCall / ApiBatch / 句柄模式),供后续导航/登录路径照搬。
  6. 现有测试不退化;新增异步路径的单测可离线运行。

非目标(本期不做)

  • WorkbenchNavController / ApiProjectRepository / AuthService / 登录流程——它们继续走同步 ApiClient.get/postJson
  • 做后台工作线程:网络是 I/O 密集,QNetworkAccessManager 原生异步即可,不引入 QThread/QtConcurrent
  • 追求与原版 loading 动画像素级一致(轻量提示即可,细抠后续单独做)。
  • LocalSampleRepository(它实现旧同步 IDatasetRepository,但不经详情控制器使用)。

3. 已定决策(及理由)

决策 选择 理由
范围 仅 DatasetDetail 路径 风险小、最痛点14s rows先解决、产出可复用模式
取消语义 主动 abort 过期请求 体验最好;句柄对象使 abort 成为一等公民
加载反馈 轻量"加载中"遮罩 + 禁用交互 不阻塞后必须给反馈;先跑通机制
异步机制 方案 A每请求一个 QObject 句柄(信号 + abort 纯 Qt 信号、贴合现有信号驱动代码库abort 用具体句柄最干净QFuture 的 cancel 不中断底层 reply反而要再加句柄测试用 QSignalSpy 顺手
多请求汇聚 fail-fast with abort(任一项失败立即回报并 abort 其余在飞 call 慢的 rows 14s若 colorScale 200ms 先失败gather-all 会让用户干等 14s 才看到失败(失败比成功还慢,违背"及时反馈"。fail-fast 依赖下方「abort 闸门」机制杜绝迟到回灌,原"避免部分-abort 竞态"的顾虑已被该机制消解。成功路径仍需全部到齐
abort 闸门(核心安全机制) 链路级 aborted_ 标志 + 句柄身份比对 见 §5.1/§5.3:仅靠 disconnect 挡不住「已 emit、正在事件队列等待派发」的迟到信号必须每层入口判 aborted_ + 控制器比对句柄身份,才能兑现"abort 后绝不回灌"的核心承诺

4. 架构

DatasetDetailController (QObject状态/编排)
   │ 调 repo.loadChartAsync(id) / loadGridAsync(id),持有返回的 *Load 句柄
   │ 切换/关页 → 句柄.abort()
   ▼
IAsyncDatasetRepository纯抽象 ←── ApiDatasetRepository 实现
   │ 每次 loadXxxAsync 创建一个 *Load 句柄,内部用 ApiBatch 并发 N 个 ApiCall
   │ batch 全部完成 → 逐项校验+解析 → emit done(Parts) / failed(msg)
   ▼
ApiClient.getAsync(path) → ApiCall包一个 QNetworkReply实现 IApiCall
   ApiClient 保留同步 get/postJson 供 Nav/登录继续使用,共享同一 QNetworkAccessManager

信号面保持不变DatasetDetailController 对外仍发 chartReady(ChartData) / gridReady(GridData) / loadFailed(dsId,msg) / focusRequested(dsId),新增 loadStarted(dsId, Phase)。因此 main.cpp 接线与 DatasetDetailPanel 几乎不动,仅接 loadStarted 显示遮罩。

5. 组件设计

5.0 核心安全不变量abort 后绝不回灌(贯穿全链路)

这是本设计成败的关键。disconnect 只能阻止「将来才发」的信号,挡不住「已经 emit、已转成 QMetaCallEvent 在事件队列中等待派发」的迟到信号(尤其在共享 NAM 同步嵌套 QEventLoop 期间,见 5.1)。因此 disconnect 仅作"尽力而为",真正的权威闸门是下面两条:

  1. 每层 aborted_ 标志 + 入口守卫ApiCall / ApiBatch / ChartLoad / GridLoad 各持一个 bool aborted_。所有 finished/done/failed入口第一行 if (aborted_) return;abort() 先置 aborted_=true 再断连/中断。聚合 emit 前同样判 aborted_
  2. 控制器句柄身份比对DatasetDetailControllerdone/failed 槽内校验「发信号的句柄 == 当前持有的 chartLoad_/gridLoad_」(用 sender() 或 lambda 捕获句柄指针比对),过期句柄的迟到信号直接丢弃。与 abort-and-replace 形成纵深防御。
  3. 销毁一律 deleteLaterApiCall/ApiBatch/*Load/QNetworkReply 在 abort 或完成后一律 deleteLater,禁止任何同步 delete——避免「abort 一个正在自己回调栈中的对象」导致 use-after-free。

5.1 net 层

IApiCall(新增,抽象,可测试缝)

// src/net/IApiCall.hpp
class IApiCall : public QObject {
    Q_OBJECT
public:
    using QObject::QObject;
    virtual void abort() = 0;          // 中断;不再 emit finisheddeleteLater
signals:
    void finished(const geopro::net::ApiResponse& resp);  // 成功/错误均经此(错误写 rawError
};

存在意义:ApiBatch 只依赖 IApiCall,单测可注入假 call不碰真实网络离线测 gather/abort。

ApiCall(新增,实现 IApiCall

  • bool aborted_=false(见 §5.0)。
  • 构造接管一个 QNetworkReply*;连接 reply->finished → 槽入口判 if (aborted_) return; → 解析为 ApiResponse(复用现 parseBody)→ emit finisheddeleteLater() 自删。
  • abort()aborted_=truedisconnect 自身与 reply 的连接 → reply->abort()reply->deleteLater()this->deleteLater()禁止同步 deletereply->abort() 本身会再触发一次 finishedOperationCanceledError已被 disconnect + aborted_ 双重挡掉。
  • QueuedConnectionApiResponseqRegisterMetaType<geopro::net::ApiResponse>()(应用启动时注册一次)。

ApiClient(扩展,不破坏现有)

net::IApiCall* getAsync(const QString& path);                       // 立即返回,不阻塞
net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
// 保留ApiResponse get(path); ApiResponse postJson(path, body);   // Nav/登录继续用
  • getAsync/postJsonAsync 用同一 nam_ 发起,返回 new ApiCall(reply)。token 注入、buildRequest 复用。
  • 已知限制(非"无害",受 §5.0 三条约束兜底)Nav 的同步嵌套 QEventLoopApiClient::getloop.exec())期间,详情的异步 reply 若完成会被一并泵出其整条槽链ApiCall→ApiBatch→*Load→Controller→chartReadydetailPanel->openOrUpdate)会在 Nav 同步调用栈、嵌套循环里重入执行,即详情 UI 重建可能发生在 Nav 网络调用返回前。这不是"互不写状态"能涵盖的,但有 §5.0 兜底:(a) 一律 deleteLater 杜绝栈内同步释放;(b) 各层 aborted_ 入口守卫;(c) 控制器句柄身份比对。本期 Nav 仍同步、窗口期有限;全异步后该同步路径与本限制一并消失。可选缓解YAGNI暂不做详情完成回调改 QueuedConnection 投递,避免在嵌套栈内驱动 UI。

ApiBatch新增汇聚原语fail-fast

// src/net/ApiBatch.hpp
class ApiBatch : public QObject {
    Q_OBJECT
public:
    // calls 接管所有权predicate 由 repo 注入,判定单个 ApiResponse 是否业务失败
    ApiBatch(QList<net::IApiCall*> calls,
             std::function<bool(const net::ApiResponse&)> isFailure,
             QObject* parent=nullptr);
    void abort();   // aborted_=trueabort 全部未完成子 callself deleteLater不 emit
signals:
    void succeeded(const QList<geopro::net::ApiResponse>& responses); // 全部成功,按下标对齐
    void failed(int index, const geopro::net::ApiResponse& resp);     // 首个失败项
};
  • 所有权契约:内部用 QList<QPointer<net::IApiCall>> 持有子 call子 call 正常 finished 后自删(QPointer 自动置空)。abort() 遍历时跳过空指针。子 call parent 不设为 batch各自 deleteLater 自管),靠 QPointer + aborted_ 防悬垂。
  • bool aborted_§5.0)。每个子 call finished 槽入口判 aborted_
  • fail-fast:每个子 call finished 到达时立即用 isFailure(resp) 判定——失败则 emit failed(i, resp) + aborted_=true + abort 其余在飞子 call + deleteLater;成功则记入 responses_[i],全部到齐 → emit succeeded(responses) + deleteLater
  • 业务/传输/HTTP 三类失败统一由 isFailure 判定(见 §7

5.2 data 层

data 层结果载体(中性,避免 data→controller 反向依赖)

// src/data/api/DatasetLoads.hpp
struct ChartParts { core::ScatterField scatter; core::ColorScale scatterScale; };
struct GridParts  { core::Grid grid{1,1}; core::ColorScale gridScale; std::vector<core::Anomaly> anomalies; };

*Load 句柄(每次加载一个,承载 typed 结果 + abort

class ChartLoad : public QObject {
    Q_OBJECT
public:
    void abort();                         // aborted_=true转发给内部 ApiBatch
signals:
    void done(const geopro::data::ChartParts& parts);
    void failed(const QString& message);
};
class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ };
  • bool aborted_§5.0);连 ApiBatch::succeeded 槽入口判 aborted_ 后解析→ emit donedeleteLater;连 ApiBatch::failed → 取 msg(见 §7→ emit faileddeleteLaterabort()aborted_ 并转发给内部 QPointer<ApiBatch>
  • 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。

旧同步方法去向(接口改形审计)

ApiDatasetRepository 不再继承 IDatasetRepository。旧 6 个同步方法去向:

旧方法 去向
loadStructure() 丢弃(详情路径不用;现实现即返回 {} 占位;结构树走 LocalSampleRepository/Nav
loadScatter + loadScatterColorScale 合并进 loadChartAsync
loadGrid + loadColorScale + loadAnomalies 合并进 loadGridAsync

LocalSampleRepository 继续实现同步 IDatasetRepository,二接口并存;buildWorkbenchrepo 形参类型不变。已核对:无任何处把 ApiDatasetRepositoryIDatasetRepository* 用于 loadStructure,丢弃安全。

IAsyncDatasetRepository(新增抽象,详情控制器依赖它)

// src/data/repo/IAsyncDatasetRepository.hpp
class IAsyncDatasetRepository {
public:
    virtual ~IAsyncDatasetRepository() = default;
    virtual data::ChartLoad* loadChartAsync(const std::string& dsId) = 0;  // scatter + scatterScale(type1)
    virtual data::GridLoad*  loadGridAsync(const std::string& dsId)  = 0;  // grid(rows) + scale(type2) + anomalies
};
  • 复合方法(而非旧 6 个原子 loadXxx):原子方法把"该发哪几个请求"泄漏给了控制器;汇聚+abort 是核心复杂度,集中放 repo它本就知道请求集是正确归属控制器保持轻薄。

ApiDatasetRepository(改造)

  • 不再继承同步 IDatasetRepository;改实现 IAsyncDatasetRepository
  • loadChartAsync:建 ApiBatch{ getScatter, getScatterScale },注入 isFailure(见 §7succeeded 时复用现有 DTO 解析(DatasetChartDto 等)填 ChartPartsdonefailed → 取错误原因并 failed
  • loadGridAsyncApiBatch{ getRows, getColorScale, getException } → 同构填 GridParts
  • 现有同步解析/校验逻辑DTO、v 行数校验、markType 钳制)与 must() 的判定(code==200原样搬入,不重写——must() 的判定提炼为 batch 的 isFailure 谓词。
  • 空数据非失败:异常列表可能为空——业务 code==200 且 data 为空走成功(空 GridParts.anomalies),与现状一致。

5.3 controller 层

DatasetDetailController(改造)

enum class Phase { Chart, Grid };
signals:
    void loadStarted(const QString& dsId, Phase phase);   // 新增:驱动加载遮罩
    // 不变chartReady(ChartData)/gridReady(GridData)/loadFailed(dsId,msg)/focusRequested(dsId)
private:
    IAsyncDatasetRepository& repo_;
    QPointer<data::ChartLoad> chartLoad_;   // 当前在飞QPointer 防悬垂)
    QPointer<data::GridLoad>  gridLoad_;
  • openDataset(dsId, ddCode)ddCode 非 dd_inversion_dataloadFailed;否则 chartLoad_ 在飞先 abort()repo_.loadChartAsync 取新句柄存入 chartLoad_emit loadStarted(dsId, Chart),连:
    • done句柄身份比对(捕获的句柄指针 == 当前 chartLoad_?否则丢弃迟到信号,见 §5.0)→ 组 ChartDatachartReady → 清空 chartLoad_
    • failed → 同样身份比对 → loadFailed → 清空 chartLoad_
  • loadGridData(dsId, ddCode):同构,针对 gridLoad_gridReady
  • 移除 busy_ 守卫:由 abort-and-replace + 句柄身份比对取代新请求来→abort 旧→发新;旧句柄的迟到信号被身份比对丢弃)。
  • 句柄完成/失败后清空对应 QPointer控制器析构时 abort 所有在飞句柄(见 §7 退出契约)。
  • 现有 catch(...) 兜底不再需要(无同步抛出路径);错误统一经 failed 信号传递。

5.4 UI 层(加载反馈)

  • 新增 LoadingOverlayQWidget,半透明 + "加载中…" + 可选 busy 指示),可贴在任一视图区上层。
  • DatasetDetailPanel
    • loadStarted(dsId, Grid) → 在对应 ds 页的网格视图上显示遮罩 + 禁用交互;gridReady/loadFailed → 隐藏。
    • 原数据初次加载(~0.8s):页面仍于 chartReady 创建;加载期用 busy 光标 + 状态栏"加载中…"(最小改动)。网格遮罩是高价值项(页已存在、等待 14s
  • 像素级对齐原版 loading 留后续。

6. 数据流(时序)

原数据(双击数据集)

  1. detailCtrl.openDataset(id, "dd_inversion_data") → emit loadStarted(id,Chart)UI 置 busy
  2. abort 旧 chartLoad_(若有)→ repo.loadChartAsync(id) 返回 ChartLoad*
  3. repo 内 ApiBatch{getScatter, getScatterScale} 并发UI 线程保持响应
  4. 两 reply 全成功到齐 → batch.succeeded → repo 解析 → ChartLoad::done(ChartParts)
  5. controller 身份比对通过 → 组 ChartDatachartReadydetailPanel->openOrUpdate(清 busy
    • 任一项失败fail-fast:首个失败 → batch.failed + abort 其余在飞 → ChartLoad::failedloadFailed → 状态栏提示(清 busy不等其余请求。

网格数据(首次切到网格页签)

  1. gridDataNeededloadGridData(id, ...) → emit loadStarted(id,Grid)(网格视图遮罩)。
  2. abort 旧 gridLoad_repo.loadGridAsync(id)
  3. ApiBatch{getRows(慢14s), getColorScale, getException} 并发。
  4. 全成功到齐 → 解析 → done(GridParts)gridReadysetGridData(隐遮罩)。若 colorScale/exception 先失败 → fail-fast 立即 failed + abort rows不干等 14s。
  5. 期间用户切走/关页 → controller abort gridLoad_ → 子 reply 全 abort + aborted_ 闸门 → 即便有迟到信号也被丢弃,无回灌。

7. 错误处理与边界

  • 失败判定三要素(isFailure 谓词,由现 must() 提炼):一个 ApiResponse 视为失败当:(1) 传输错误 !rawError.isEmpty();或 (2) HTTP 异常(必要时查 httpStatus);或 (3) 业务码 code != 200(服务端常返回 HTTP 200 但 code=500,这是现 must() 的判定口径,不能只看 httpStatus)。失败原因取 msg(空则 rawError)。
  • abort 竞态(核心,见 §5.0:单靠 disconnect 不足以挡「已入队的迟到信号」。权威闸门 = 各层 aborted_ 入口守卫 + 控制器句柄身份比对 + 一律 deleteLaterApiCall::abortaborted_ 后 disconnect+reply->abort()
  • 重复触发abort-and-replace + 身份比对天然幂等(新覆旧,旧迟到信号丢弃)。
  • 控制器析构(退出契约)abort 所有在飞句柄。ApiCall/ApiBatch/*Load 不得在析构或 deleteLater 回调里访问 nam/replyreply 在 abort 时已 deleteLater。main.cpp 栈对象析构逆序为 detailCtrldatasetRepoapi,确保控制器先 abort、ApiClient 最后毁;句柄不持有对 nam 的长期引用。
  • 共享 NAM 同步/异步并存:见 §5.1已知限制(非"无害"),受 §5.0 三约束兜底,全异步后消除。
  • 空/缺数据:异常列表可能为空——业务 code==200done(空),非 failed,与现状一致。

8. 测试策略

  • ApiBatch(离线单测):注入假 IApiCall(受测可控 emit finished/记录 abort)。
    • 全成功 → succeeded 一次、下标对齐。
    • fail-fast:某子 call 先返回失败 → failed(i,resp) 一次 + 其余在飞子 call 被 abort(且不再等待)。
    • abort 闸门batch abort() 后,未完成子 call 均被 abort之后即便假 call 延迟 emit finishedbatch 也不 emitaborted_ 守卫)。
  • DatasetDetailController离线单测QSignalSpy + 事件循环):用 StubAsyncRepo 返回假 *Load,可控 emit done/failed(即时或 QMetaObject::invokeMethod(..., QueuedConnection) 模拟迟到)。异步桩非即时返回,断言需 spy.wait() / QTest::qWait spin 事件循环。
    • done → 一次 chartReady/gridReadyfailed → 一次 loadFailed;非 dd_inversion_data → 直接 loadFailed
    • 在飞时再 openDataset → 旧句柄被 abortstub 记录)。
    • 回灌防护(回归原 bug 的核心用例):① 句柄 abort 后延迟 emit done → 控制器 chartReady(身份比对 + aborted_ 生效);② openDataset(A) 在飞 → openDataset(B) → A 句柄迟到 emit done仅 B 的数据被 chartReadyA 丢弃
  • ApiCall/getAsync(可选 live test:依现有 AuthLiveTest 先例,标记为 live不计入离线覆盖门槛。
  • 现有 75 个测试中,DatasetDetailController.* 两个改为异步桩版本(加事件循环 spin其余不动。LocalRepo.* 不受影响(同步接口保留)。
  • 目标异步新增逻辑batch/controller离线单测覆盖 ≥ 80%。

9. 迁移步骤(供 writing-plans 细化)

  1. netIApiCall + ApiCall + ApiClient::getAsync/postJsonAsync(保留同步)。parseBody 抽为可复用。
  2. netApiBatch + 离线单测。
  3. dataDatasetLoads.hppChartParts/GridParts+ ChartLoad/GridLoad + IAsyncDatasetRepository
  4. dataApiDatasetRepository 改实现异步接口(搬入现有 DTO 解析)。
  5. controllerDatasetDetailController 改异步 + abort-and-replace + loadStarted;改其单测。
  6. main.cpp 装配换 IAsyncDatasetRepository + 启动注册 qRegisterMetaType<ApiResponse>()
  7. UILoadingOverlay + DatasetDetailPanelloadStarted此步与异步内核无依赖,可在步骤 16 通过测试后再做/并行,以减小试点 PR 体积surgical
  8. 全量构建 + 测试dev-build / dev-test手动验证切换/关页 abort、网格遮罩、并发加载、UI 不冻、失败 fail-fast 即时报错。

10. 风险

风险 缓解
已入队的迟到信号回灌(最危险) §5.0 闸门:各层 aborted_ 入口守卫 + 控制器句柄身份比对 + 一律 deleteLater§8 专项回归用例
abort 后回调悬垂/二次释放 QPointer + abort 先置 aborted_+disconnect + 禁止同步 deletedeleteLater
句柄/batch 生命周期泄漏 完成或 abort 后统一 deleteLaterApiBatchQPointer 持子 call
同步/异步共用 NAM 嵌套重入 已知限制非无害§5.0 三约束兜底;全异步后消除
退出期 UAF句柄 deleteLater 晚于 ApiClient 析构) 退出契约:句柄不在析构/回调访问 nam/reply栈析构逆序保证 ctrl→repo→api
失败比成功慢gather-all 缺陷) 改 fail-fast + abort 其余在飞 call
DTO 解析逻辑搬迁出错 原样搬入、不重写;must()isFailure 谓词;保留现有解析单测
接口改形(原子→复合)波及面 仅详情控制器消费该接口§5.2 旧方法去向表;LocalSampleRepository 不碰

11. 相关文件

  • 改:src/net/ApiClient.{hpp,cpp}src/data/api/ApiDatasetRepository.{hpp,cpp}src/controller/DatasetDetailController.{hpp,cpp}src/app/main.cppsrc/app/panels/.../DatasetDetailPanel.*tests/controller/test_dataset_detail_controller.cpp、各 CMakeLists.txt
  • 新:src/net/IApiCall.hppsrc/net/ApiCall.{hpp,cpp}src/net/ApiBatch.{hpp,cpp}src/data/api/DatasetLoads.hppsrc/data/api/DatasetLoadHandles.{hpp,cpp}(ChartLoad/GridLoad)、src/data/repo/IAsyncDatasetRepository.hppsrc/app/.../LoadingOverlay.{hpp,cpp}tests/net/test_api_batch.cpp
  • 不动:src/data/repo/IDatasetRepository.hpp(同步,留给 LocalSampleRepository、Nav/Auth/Project 路径。