1364 lines
50 KiB
Markdown
1364 lines
50 KiB
Markdown
# ApiClient 异步化(DatasetDetail 路径)实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 把数据集详情路径(原数据 + 网格数据)从同步阻塞改为 Qt 原生异步,UI 全程不冻、慢请求可 abort、失败 fail-fast,作为全 App 异步化的模式样板。
|
||
|
||
**Architecture:** `ApiClient` 新增 `getAsync/postJsonAsync` 返回自管理 QObject 句柄 `ApiCall`(实现可测的 `IApiCall`);`ApiBatch` 并发汇聚多请求并 fail-fast;data 层 `ApiDatasetRepository` 用 batch 组装 `ChartLoad`/`GridLoad` 句柄(抽象基类 + Api 实现,供 stub 测试);`DatasetDetailController` 改 abort-and-replace + 句柄身份比对,对外信号面不变。核心安全靠「各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 deleteLater」杜绝迟到信号回灌。
|
||
|
||
**Tech Stack:** Qt6 (Core/Network/Test)、QNetworkAccessManager 原生异步、CMake + Ninja + MSVC、GoogleTest/CTest、QSignalSpy。
|
||
|
||
**权威设计:** `docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(含 §5.0 安全不变量、§7 错误判定、§8 测试策略)。
|
||
|
||
**构建/测试命令:**
|
||
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
- 若报 `LNK1104 ... geopro_desktop.exe`:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。
|
||
- 全量测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(基线 75/75 绿)
|
||
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiBatch.*`
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
**新建:**
|
||
- `src/net/IApiCall.hpp` — 抽象 QObject 缝:`finished(ApiResponse)` 信号 + 纯虚 `abort()`。
|
||
- `src/net/ApiCall.hpp` / `src/net/ApiCall.cpp` — 包一个 `QNetworkReply` 的 `IApiCall` 实现。
|
||
- `src/net/ApiResponseParse.hpp` / `src/net/ApiResponseParse.cpp` — `buildResponse(QNetworkReply*)`(从现 `Impl::await` 抽出解析部分,sync/async 共用,DRY)。
|
||
- `src/net/ApiBatch.hpp` / `src/net/ApiBatch.cpp` — 并发汇聚 + fail-fast 原语。
|
||
- `src/data/api/DatasetLoads.hpp` — `ChartParts` / `GridParts` 结果载体(纯结构体)。
|
||
- `src/data/api/DatasetLoadHandles.hpp` / `src/data/api/DatasetLoadHandles.cpp` — 抽象 `ChartLoad`/`GridLoad`(QObject 基类)+ 具体 `ApiChartLoad`/`ApiGridLoad`(包 ApiBatch + 解析)。
|
||
- `src/data/repo/IAsyncDatasetRepository.hpp` — 详情异步仓储抽象(返回 `ChartLoad*`/`GridLoad*`)。
|
||
- `tests/net/FakeApiCall.hpp` — 测试假 call(**不声明 Q_OBJECT**,发射继承的 `finished`、记录 `abort`)。
|
||
- `tests/net/test_api_batch.cpp` — ApiBatch 离线单测。
|
||
- `tests/data/test_dataset_load_handles.cpp` — ChartLoad/GridLoad 句柄离线单测。
|
||
- `src/app/panels/LoadingOverlay.hpp` / `src/app/panels/LoadingOverlay.cpp` — 轻量「加载中」遮罩(Task 8,可后置)。
|
||
|
||
**修改:**
|
||
- `src/net/ApiClient.hpp` / `.cpp` — 加 `getAsync/postJsonAsync`;`await` 改用 `buildResponse`;`ApiResponse` 加 `Q_DECLARE_METATYPE`。
|
||
- `src/net/CMakeLists.txt` — AUTOMOC ON;加 ApiCall/ApiBatch/ApiResponseParse 源。
|
||
- `src/data/api/ApiDatasetRepository.hpp` / `.cpp` — 改实现 `IAsyncDatasetRepository`。
|
||
- `src/data/CMakeLists.txt` — AUTOMOC ON;加 DatasetLoadHandles 源。
|
||
- `src/controller/DatasetDetailController.hpp` / `.cpp` — 依赖 `IAsyncDatasetRepository`、abort-and-replace、`loadStarted` 信号。
|
||
- `tests/controller/test_dataset_detail_controller.cpp` — 改异步 stub + 回灌防护用例。
|
||
- `tests/CMakeLists.txt` — 加 test_api_batch / test_dataset_load_handles。
|
||
- `src/app/main.cpp` — `qRegisterMetaType<ApiResponse>()`;接 `loadStarted`(Task 8)。
|
||
- `src/app/CMakeLists.txt` — 加 LoadingOverlay(Task 8)。
|
||
|
||
**不动:** `src/data/repo/IDatasetRepository.hpp`(同步,留 LocalSampleRepository)、Nav/Auth/Project 路径、`src/net/AuthService.*`。
|
||
|
||
---
|
||
|
||
## Task 1: net — 抽出 `buildResponse`(纯重构,行为不变)
|
||
|
||
把现 `ApiClient::Impl::await` 里「读状态 + 读体 + 解析」的部分抽成自由函数,供同步 `await` 与异步 `ApiCall` 复用(DRY)。
|
||
|
||
**Files:**
|
||
- Create: `src/net/ApiResponseParse.hpp`, `src/net/ApiResponseParse.cpp`
|
||
- Modify: `src/net/ApiClient.cpp`, `src/net/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写 `ApiResponseParse.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include "ApiClient.hpp" // for geopro::net::ApiResponse
|
||
|
||
class QNetworkReply;
|
||
|
||
namespace geopro::net {
|
||
|
||
// 从一个【已完成】的 reply 读取状态码/响应体/错误并解析为 ApiResponse。
|
||
// 不等待、不删除 reply(调用方负责生命周期)。供同步 await 与异步 ApiCall 共用。
|
||
ApiResponse buildResponse(QNetworkReply* reply);
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 2: 写 `ApiResponseParse.cpp`**(把现 ApiClient.cpp 的 parseBody + await 后半段搬来)
|
||
|
||
```cpp
|
||
#include "ApiResponseParse.hpp"
|
||
|
||
#include <QByteArray>
|
||
#include <QJsonDocument>
|
||
#include <QJsonParseError>
|
||
#include <QJsonValue>
|
||
#include <QNetworkReply>
|
||
#include <QNetworkRequest>
|
||
|
||
namespace geopro::net {
|
||
|
||
namespace {
|
||
constexpr int kHttpStatusUnset = 0;
|
||
|
||
void parseBody(const QByteArray& body, ApiResponse& resp) {
|
||
if (body.isEmpty()) {
|
||
resp.rawError = QStringLiteral("empty response body");
|
||
return;
|
||
}
|
||
QJsonParseError perr{};
|
||
const QJsonDocument doc = QJsonDocument::fromJson(body, &perr);
|
||
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
|
||
resp.rawError = QStringLiteral("JSON parse error: %1; body: %2")
|
||
.arg(perr.errorString(), QString::fromUtf8(body));
|
||
return;
|
||
}
|
||
const QJsonObject obj = doc.object();
|
||
resp.code = obj.value(QStringLiteral("code")).toInt();
|
||
resp.msg = obj.value(QStringLiteral("msg")).toString();
|
||
const QJsonValue dataVal = obj.value(QStringLiteral("data"));
|
||
if (dataVal.isObject()) {
|
||
resp.data = dataVal.toObject();
|
||
} else {
|
||
resp.data = QJsonObject{{QStringLiteral("value"), dataVal}};
|
||
}
|
||
}
|
||
} // namespace
|
||
|
||
ApiResponse buildResponse(QNetworkReply* reply) {
|
||
ApiResponse resp;
|
||
const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
|
||
resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset;
|
||
const QByteArray body = reply->readAll();
|
||
if (reply->error() != QNetworkReply::NoError) {
|
||
resp.rawError = reply->errorString();
|
||
}
|
||
if (!body.isEmpty()) {
|
||
parseBody(body, resp);
|
||
}
|
||
return resp;
|
||
}
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 3: 改 `ApiClient.cpp` 用 buildResponse**
|
||
|
||
删除 ApiClient.cpp 顶部 `parseBody` 匿名函数(已搬到 ApiResponseParse.cpp)与 `kHttpStatusUnset`、相关未用 include(`QJsonDocument/QJsonParseError/QJsonValue`)。在文件顶部 include 改为加 `#include "ApiResponseParse.hpp"`。把 `Impl::await` 改为:
|
||
|
||
```cpp
|
||
static ApiResponse await(QNetworkReply* reply) {
|
||
QEventLoop loop;
|
||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||
loop.exec();
|
||
return buildResponse(reply);
|
||
}
|
||
```
|
||
|
||
保留 `kContentTypeJson` / `kTokenHeader` / `buildRequest` 不动。
|
||
|
||
- [ ] **Step 4: 注册到 CMake**
|
||
|
||
`src/net/CMakeLists.txt` 的 `add_library(geopro_net STATIC ...)` 列表加一行 `ApiResponseParse.cpp`:
|
||
|
||
```cmake
|
||
add_library(geopro_net STATIC
|
||
crypto/RsaEncryptor.cpp
|
||
ApiClient.cpp
|
||
ApiResponseParse.cpp
|
||
AuthService.cpp)
|
||
```
|
||
|
||
- [ ] **Step 5: 构建 + 跑现有测试(回归安全网)**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: 75/75 PASS(`AuthLiveTest.FullLoginFlowReturnsToken` 走 sync 路径会经过 buildResponse,验证重构无回归)。
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/net/ApiResponseParse.hpp src/net/ApiResponseParse.cpp src/net/ApiClient.cpp src/net/CMakeLists.txt
|
||
git commit -m "refactor(net): 抽出 buildResponse,sync/async 共用响应解析(DRY,行为不变)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: net — `IApiCall` + `ApiCall` + AUTOMOC + getAsync/postJsonAsync
|
||
|
||
**Files:**
|
||
- Create: `src/net/IApiCall.hpp`, `src/net/ApiCall.hpp`, `src/net/ApiCall.cpp`
|
||
- Modify: `src/net/ApiClient.hpp`, `src/net/ApiClient.cpp`, `src/net/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写 `IApiCall.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <QObject>
|
||
#include "ApiClient.hpp" // geopro::net::ApiResponse
|
||
|
||
namespace geopro::net {
|
||
|
||
// 单个异步请求的抽象句柄(可测试缝)。ApiBatch 仅依赖此抽象,单测可注入假 call。
|
||
class IApiCall : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
using QObject::QObject;
|
||
~IApiCall() override = default;
|
||
virtual void abort() = 0; // 置 aborted_、中断、deleteLater;abort 后绝不 emit finished
|
||
signals:
|
||
void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError)
|
||
};
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 2: 写 `ApiCall.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <QPointer>
|
||
#include "IApiCall.hpp"
|
||
|
||
class QNetworkReply;
|
||
|
||
namespace geopro::net {
|
||
|
||
// 包一个 QNetworkReply 的 IApiCall 实现。reply 完成 → buildResponse → emit finished → 自删。
|
||
// 安全不变量(spec §5.0):abort 后绝不 emit;销毁一律 deleteLater,禁止同步 delete。
|
||
class ApiCall : public IApiCall {
|
||
Q_OBJECT
|
||
public:
|
||
explicit ApiCall(QNetworkReply* reply, QObject* parent = nullptr); // 接管 reply
|
||
void abort() override;
|
||
private:
|
||
void onFinished();
|
||
QPointer<QNetworkReply> reply_;
|
||
bool aborted_ = false;
|
||
};
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 3: 写 `ApiCall.cpp`**
|
||
|
||
```cpp
|
||
#include "ApiCall.hpp"
|
||
|
||
#include <QNetworkReply>
|
||
#include "ApiResponseParse.hpp"
|
||
|
||
namespace geopro::net {
|
||
|
||
ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) {
|
||
QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished);
|
||
}
|
||
|
||
void ApiCall::onFinished() {
|
||
if (aborted_) return; // §5.0 入口守卫:迟到信号闸门
|
||
ApiResponse resp = buildResponse(reply_);
|
||
if (reply_) reply_->deleteLater();
|
||
emit finished(resp);
|
||
deleteLater();
|
||
}
|
||
|
||
void ApiCall::abort() {
|
||
if (aborted_) return;
|
||
aborted_ = true; // 先置标志(权威闸门)
|
||
if (reply_) {
|
||
QObject::disconnect(reply_, nullptr, this, nullptr); // 尽力而为:断开未派发连接
|
||
reply_->abort(); // 会再触发一次 finished,已被 disconnect+aborted_ 双挡
|
||
reply_->deleteLater();
|
||
}
|
||
deleteLater(); // 禁止同步 delete
|
||
}
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 4: 改 `ApiClient.hpp`** — 加异步方法 + 元类型声明
|
||
|
||
在 `ApiResponse` 结构体定义之后(`};` 之下、`class ApiClient` 之上)插入:
|
||
|
||
```cpp
|
||
} // namespace geopro::net ←★ 不要重复;见下方完整片段
|
||
```
|
||
|
||
具体改法:①在 `class ApiClient` 内 `postJson` 声明后加两行;②文件末尾 `} // namespace geopro::net` 之后加 `Q_DECLARE_METATYPE`。
|
||
|
||
`class ApiClient` 内新增:
|
||
```cpp
|
||
// 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。
|
||
net::IApiCall* getAsync(const QString& path);
|
||
net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
|
||
```
|
||
(注意:此处在 `namespace geopro::net` 内,写 `IApiCall*` 即可,不必加 `net::` 前缀——按现文件风格用 `IApiCall*`。)
|
||
|
||
前置声明区加 `class IApiCall;`(与现有 `class QNetworkAccessManager;` 并列)。
|
||
|
||
文件最末(`} // namespace geopro::net` 之后)加:
|
||
```cpp
|
||
#include <QMetaType>
|
||
Q_DECLARE_METATYPE(geopro::net::ApiResponse)
|
||
```
|
||
|
||
- [ ] **Step 5: 改 `ApiClient.cpp`** — 实现异步方法
|
||
|
||
顶部加 `#include "ApiCall.hpp"`。在 `postJson` 实现之后加:
|
||
|
||
```cpp
|
||
IApiCall* ApiClient::getAsync(const QString& path) {
|
||
QNetworkRequest req = impl_->buildRequest(path);
|
||
QNetworkReply* reply = impl_->nam.get(req);
|
||
return new ApiCall(reply);
|
||
}
|
||
|
||
IApiCall* ApiClient::postJsonAsync(const QString& path, const QJsonObject& body) {
|
||
QNetworkRequest req = impl_->buildRequest(path);
|
||
const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact);
|
||
QNetworkReply* reply = impl_->nam.post(req, payload);
|
||
return new ApiCall(reply);
|
||
}
|
||
```
|
||
(`QJsonDocument` 已被 ApiClient.cpp 使用,保留其 include。)
|
||
|
||
- [ ] **Step 6: 改 `src/net/CMakeLists.txt`** — AUTOMOC ON + 加源
|
||
|
||
```cmake
|
||
add_library(geopro_net STATIC
|
||
crypto/RsaEncryptor.cpp
|
||
ApiClient.cpp
|
||
ApiResponseParse.cpp
|
||
ApiCall.cpp
|
||
AuthService.cpp)
|
||
```
|
||
并把末行改为:
|
||
```cmake
|
||
set_target_properties(geopro_net PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)
|
||
```
|
||
(AUTOMOC ON 仅对含 Q_OBJECT 的新文件生效,现有文件无 Q_OBJECT,无副作用。`IApiCall.hpp`/`ApiCall.hpp` 经 `ApiCall.cpp` 的 `#include` 被 AUTOMOC 扫描到。)
|
||
|
||
- [ ] **Step 7: 构建(无新测试,验证编译/链接/moc)**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
Expected: 链接通过(moc 为 IApiCall/ApiCall 生成成功)。
|
||
> ApiCall 直接包 QNetworkReply,离线无法稳定单测,由 Task 3(ApiBatch + FakeApiCall)与 Task 6(控制器集成)+ Task 8 手动验证覆盖。
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/net/IApiCall.hpp src/net/ApiCall.hpp src/net/ApiCall.cpp src/net/ApiClient.hpp src/net/ApiClient.cpp src/net/CMakeLists.txt
|
||
git commit -m "feat(net): ApiClient.getAsync/postJsonAsync + IApiCall/ApiCall 异步句柄(abort+aborted_ 闸门,AUTOMOC ON)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: net — `ApiBatch`(fail-fast 汇聚)+ 离线单测(TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/net/ApiBatch.hpp`, `src/net/ApiBatch.cpp`, `tests/net/FakeApiCall.hpp`, `tests/net/test_api_batch.cpp`
|
||
- Modify: `src/net/CMakeLists.txt`, `tests/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写 `tests/net/FakeApiCall.hpp`**(不声明 Q_OBJECT:发射继承自 IApiCall 的 finished)
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include "IApiCall.hpp"
|
||
|
||
namespace geopro::net::test {
|
||
|
||
// 测试假 call。不加 Q_OBJECT —— 仅发射继承自 IApiCall 的 finished 信号、override abort。
|
||
class FakeApiCall : public IApiCall {
|
||
public:
|
||
using IApiCall::IApiCall;
|
||
bool aborted = false;
|
||
void abort() override { aborted = true; }
|
||
void fire(const ApiResponse& r) { emit finished(r); } // 发射继承的信号(无需自身 moc)
|
||
};
|
||
|
||
} // namespace geopro::net::test
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试 `tests/net/test_api_batch.cpp`**
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <QSignalSpy>
|
||
#include "ApiBatch.hpp"
|
||
#include "net/FakeApiCall.hpp"
|
||
|
||
using namespace geopro::net;
|
||
using geopro::net::test::FakeApiCall;
|
||
|
||
namespace {
|
||
ApiResponse ok(int code = 200) { ApiResponse r; r.code = code; r.httpStatus = 200; return r; }
|
||
ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; }
|
||
// repo 注入的失败谓词:业务码 != 200 或传输错误。
|
||
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
|
||
}
|
||
|
||
TEST(ApiBatch, SucceedsWhenAllOk) {
|
||
auto* a = new FakeApiCall;
|
||
auto* b = new FakeApiCall;
|
||
auto* batch = new ApiBatch({a, b}, isFailure);
|
||
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
|
||
QSignalSpy failSpy(batch, &ApiBatch::failed);
|
||
a->fire(ok());
|
||
EXPECT_EQ(okSpy.count(), 0); // 还差 b
|
||
b->fire(ok());
|
||
EXPECT_EQ(okSpy.count(), 1); // 全到齐
|
||
EXPECT_EQ(failSpy.count(), 0);
|
||
}
|
||
|
||
TEST(ApiBatch, FailFastAbortsOthers) {
|
||
auto* a = new FakeApiCall;
|
||
auto* b = new FakeApiCall; // 慢的(永不 fire)
|
||
auto* batch = new ApiBatch({a, b}, isFailure);
|
||
QSignalSpy failSpy(batch, &ApiBatch::failed);
|
||
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
|
||
a->fire(bad()); // 首个失败
|
||
EXPECT_EQ(failSpy.count(), 1);
|
||
EXPECT_EQ(okSpy.count(), 0);
|
||
EXPECT_TRUE(b->aborted); // 其余在飞被 abort
|
||
}
|
||
|
||
TEST(ApiBatch, AbortGateSuppressesLateSignals) {
|
||
auto* a = new FakeApiCall;
|
||
auto* b = new FakeApiCall;
|
||
auto* batch = new ApiBatch({a, b}, isFailure);
|
||
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
|
||
QSignalSpy failSpy(batch, &ApiBatch::failed);
|
||
batch->abort();
|
||
EXPECT_TRUE(a->aborted);
|
||
EXPECT_TRUE(b->aborted);
|
||
a->fire(ok()); // 迟到信号
|
||
b->fire(ok());
|
||
EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门:不 emit
|
||
EXPECT_EQ(failSpy.count(), 0);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 注册测试到 CMake**
|
||
|
||
`tests/CMakeLists.txt`,在 net 段(`target_sources(geopro_tests PRIVATE net/test_auth.cpp)` 之后)加:
|
||
```cmake
|
||
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
|
||
```
|
||
并确保 net 段链接含 `Qt6::Test`(用于 QSignalSpy)。把第 50 行链接行改为:
|
||
```cmake
|
||
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test)
|
||
```
|
||
(`find_package(Qt6 COMPONENTS Test REQUIRED)` 在 controller 段已有;若 net 段在其之前编译报错,把该 find_package 上移到文件 net 段之前。)
|
||
`tests/net/` 头由 `target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests)` 提供——若不存在则加:
|
||
```cmake
|
||
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests)
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认失败**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
Expected: 编译失败(`ApiBatch.hpp` 不存在)。
|
||
|
||
- [ ] **Step 5: 写 `src/net/ApiBatch.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <functional>
|
||
#include <QList>
|
||
#include <QPointer>
|
||
#include <QObject>
|
||
#include "IApiCall.hpp"
|
||
|
||
namespace geopro::net {
|
||
|
||
// 并发汇聚 N 个 IApiCall:全成功 → succeeded(按下标对齐);任一失败 → fail-fast:
|
||
// failed(index,resp) + abort 其余在飞 call。安全不变量见 spec §5.0。
|
||
class ApiBatch : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
using Predicate = std::function<bool(const ApiResponse&)>;
|
||
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls
|
||
void abort();
|
||
signals:
|
||
void succeeded(const QList<geopro::net::ApiResponse>& responses);
|
||
void failed(int index, const geopro::net::ApiResponse& resp);
|
||
private:
|
||
QList<QPointer<IApiCall>> calls_;
|
||
QList<ApiResponse> responses_;
|
||
Predicate isFailure_;
|
||
int remaining_ = 0;
|
||
bool aborted_ = false;
|
||
};
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 6: 写 `src/net/ApiBatch.cpp`**
|
||
|
||
```cpp
|
||
#include "ApiBatch.hpp"
|
||
|
||
namespace geopro::net {
|
||
|
||
ApiBatch::ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent)
|
||
: QObject(parent), isFailure_(std::move(isFailure)) {
|
||
responses_.resize(calls.size());
|
||
remaining_ = static_cast<int>(calls.size());
|
||
for (int i = 0; i < calls.size(); ++i) {
|
||
IApiCall* c = calls[i];
|
||
calls_.append(c);
|
||
QObject::connect(c, &IApiCall::finished, this, [this, i](const ApiResponse& resp) {
|
||
if (aborted_) return; // §5.0 入口守卫
|
||
if (isFailure_(resp)) {
|
||
aborted_ = true;
|
||
for (const auto& other : calls_) { // fail-fast:abort 其余在飞
|
||
if (other) other->abort();
|
||
}
|
||
emit failed(i, resp);
|
||
deleteLater();
|
||
return;
|
||
}
|
||
responses_[i] = resp;
|
||
if (--remaining_ == 0) {
|
||
emit succeeded(responses_);
|
||
deleteLater();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
void ApiBatch::abort() {
|
||
if (aborted_) return;
|
||
aborted_ = true;
|
||
for (const auto& c : calls_) {
|
||
if (c) c->abort();
|
||
}
|
||
deleteLater();
|
||
}
|
||
|
||
} // namespace geopro::net
|
||
```
|
||
|
||
- [ ] **Step 7: 加 ApiBatch.cpp 到 net 库**
|
||
|
||
`src/net/CMakeLists.txt` 的源列表加 `ApiBatch.cpp`:
|
||
```cmake
|
||
add_library(geopro_net STATIC
|
||
crypto/RsaEncryptor.cpp
|
||
ApiClient.cpp
|
||
ApiResponseParse.cpp
|
||
ApiCall.cpp
|
||
ApiBatch.cpp
|
||
AuthService.cpp)
|
||
```
|
||
|
||
- [ ] **Step 8: 跑测试确认通过**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: `ApiBatch.SucceedsWhenAllOk` / `FailFastAbortsOthers` / `AbortGateSuppressesLateSignals` PASS;总数 78/78。
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/net/ApiBatch.hpp src/net/ApiBatch.cpp src/net/CMakeLists.txt tests/net/FakeApiCall.hpp tests/net/test_api_batch.cpp tests/CMakeLists.txt
|
||
git commit -m "feat(net): ApiBatch 并发汇聚+fail-fast+abort闸门 + 离线单测"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: data — 结果载体 + 异步接口 + 句柄(抽象基 + Api 实现)+ 句柄单测(TDD)
|
||
|
||
**Files:**
|
||
- Create: `src/data/api/DatasetLoads.hpp`, `src/data/repo/IAsyncDatasetRepository.hpp`, `src/data/api/DatasetLoadHandles.hpp`, `src/data/api/DatasetLoadHandles.cpp`, `tests/data/test_dataset_load_handles.cpp`
|
||
- Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写 `src/data/api/DatasetLoads.hpp`**(纯结构体)
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <vector>
|
||
#include "model/Field.hpp"
|
||
#include "model/ColorScale.hpp"
|
||
#include "model/Anomaly.hpp"
|
||
|
||
namespace geopro::data {
|
||
|
||
// 原数据加载结果:scatter + 散点色阶(type1)。
|
||
struct ChartParts {
|
||
geopro::core::ScatterField scatter;
|
||
geopro::core::ColorScale scatterScale;
|
||
};
|
||
// 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。
|
||
struct GridParts {
|
||
geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位
|
||
geopro::core::ColorScale gridScale;
|
||
std::vector<geopro::core::Anomaly> anomalies;
|
||
};
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 2: 写 `src/data/api/DatasetLoadHandles.hpp`**(抽象基 + Api 实现)
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <functional>
|
||
#include <QList>
|
||
#include <QObject>
|
||
#include <QPointer>
|
||
#include <QString>
|
||
#include "ApiBatch.hpp"
|
||
#include "DatasetLoads.hpp"
|
||
|
||
namespace geopro::data {
|
||
|
||
// ── 抽象句柄(可测试缝,类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它 ──
|
||
class ChartLoad : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
using QObject::QObject;
|
||
~ChartLoad() override = default;
|
||
virtual void abort() = 0;
|
||
signals:
|
||
void done(const geopro::data::ChartParts& parts);
|
||
void failed(const QString& message);
|
||
};
|
||
|
||
class GridLoad : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
using QObject::QObject;
|
||
~GridLoad() override = default;
|
||
virtual void abort() = 0;
|
||
signals:
|
||
void done(const geopro::data::GridParts& parts);
|
||
void failed(const QString& message);
|
||
};
|
||
|
||
// ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→done;failed→failed ──
|
||
class ApiChartLoad : public ChartLoad {
|
||
Q_OBJECT
|
||
public:
|
||
using Parser = std::function<ChartParts(const QList<geopro::net::ApiResponse>&)>;
|
||
ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
|
||
void abort() override;
|
||
private:
|
||
QPointer<geopro::net::ApiBatch> batch_;
|
||
Parser parse_;
|
||
bool aborted_ = false;
|
||
};
|
||
|
||
class ApiGridLoad : public GridLoad {
|
||
Q_OBJECT
|
||
public:
|
||
using Parser = std::function<GridParts(const QList<geopro::net::ApiResponse>&)>;
|
||
ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
|
||
void abort() override;
|
||
private:
|
||
QPointer<geopro::net::ApiBatch> batch_;
|
||
Parser parse_;
|
||
bool aborted_ = false;
|
||
};
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 3: 写 `src/data/api/DatasetLoadHandles.cpp`**
|
||
|
||
```cpp
|
||
#include "api/DatasetLoadHandles.hpp"
|
||
#include <stdexcept>
|
||
|
||
namespace geopro::data {
|
||
|
||
namespace {
|
||
QString reasonOf(const geopro::net::ApiResponse& r) {
|
||
return r.msg.isEmpty() ? r.rawError : r.msg;
|
||
}
|
||
} // namespace
|
||
|
||
ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent)
|
||
: ChartLoad(parent), batch_(batch), parse_(std::move(parse)) {
|
||
QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this,
|
||
[this](const QList<geopro::net::ApiResponse>& resps) {
|
||
if (aborted_) return; // §5.0
|
||
try {
|
||
emit done(parse_(resps));
|
||
} catch (const std::exception& e) {
|
||
emit failed(QString::fromUtf8(e.what()));
|
||
}
|
||
deleteLater();
|
||
});
|
||
QObject::connect(batch, &geopro::net::ApiBatch::failed, this,
|
||
[this](int, const geopro::net::ApiResponse& r) {
|
||
if (aborted_) return;
|
||
emit failed(reasonOf(r));
|
||
deleteLater();
|
||
});
|
||
}
|
||
|
||
void ApiChartLoad::abort() {
|
||
if (aborted_) return;
|
||
aborted_ = true;
|
||
if (batch_) batch_->abort();
|
||
deleteLater();
|
||
}
|
||
|
||
ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent)
|
||
: GridLoad(parent), batch_(batch), parse_(std::move(parse)) {
|
||
QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this,
|
||
[this](const QList<geopro::net::ApiResponse>& resps) {
|
||
if (aborted_) return;
|
||
try {
|
||
emit done(parse_(resps));
|
||
} catch (const std::exception& e) {
|
||
emit failed(QString::fromUtf8(e.what()));
|
||
}
|
||
deleteLater();
|
||
});
|
||
QObject::connect(batch, &geopro::net::ApiBatch::failed, this,
|
||
[this](int, const geopro::net::ApiResponse& r) {
|
||
if (aborted_) return;
|
||
emit failed(reasonOf(r));
|
||
deleteLater();
|
||
});
|
||
}
|
||
|
||
void ApiGridLoad::abort() {
|
||
if (aborted_) return;
|
||
aborted_ = true;
|
||
if (batch_) batch_->abort();
|
||
deleteLater();
|
||
}
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 4: 写 `src/data/repo/IAsyncDatasetRepository.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <string>
|
||
|
||
namespace geopro::data {
|
||
|
||
class ChartLoad;
|
||
class GridLoad;
|
||
|
||
// 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater)。
|
||
class IAsyncDatasetRepository {
|
||
public:
|
||
virtual ~IAsyncDatasetRepository() = default;
|
||
virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1)
|
||
virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常
|
||
};
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 5: 写失败测试 `tests/data/test_dataset_load_handles.cpp`**(用真 ApiBatch + FakeApiCall + 桩解析,验证句柄 done/failed/abort 接线)
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <QSignalSpy>
|
||
#include "api/DatasetLoadHandles.hpp"
|
||
#include "net/FakeApiCall.hpp"
|
||
|
||
using namespace geopro::data;
|
||
using geopro::net::ApiBatch;
|
||
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(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) {
|
||
auto* a = new FakeApiCall;
|
||
auto* b = new FakeApiCall;
|
||
auto* batch = new ApiBatch({a, b}, isFailure);
|
||
bool parsed = false;
|
||
auto* load = new ApiChartLoad(batch, [&](const QList<ApiResponse>& resps) {
|
||
parsed = (resps.size() == 2);
|
||
return ChartParts{};
|
||
});
|
||
QSignalSpy doneSpy(load, &ChartLoad::done);
|
||
a->fire(ok());
|
||
b->fire(ok());
|
||
EXPECT_EQ(doneSpy.count(), 1);
|
||
EXPECT_TRUE(parsed);
|
||
}
|
||
|
||
TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) {
|
||
auto* a = new FakeApiCall;
|
||
auto* b = new FakeApiCall;
|
||
auto* batch = new ApiBatch({a, b}, isFailure);
|
||
auto* load = new ApiChartLoad(batch, [](const QList<ApiResponse>&) { return ChartParts{}; });
|
||
QSignalSpy failSpy(load, &ChartLoad::failed);
|
||
a->fire(bad());
|
||
EXPECT_EQ(failSpy.count(), 1);
|
||
}
|
||
|
||
TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) {
|
||
auto* a = new FakeApiCall;
|
||
auto* batch = new ApiBatch({a}, isFailure);
|
||
auto* load = new ApiGridLoad(batch, [](const QList<ApiResponse>&) { return GridParts{}; });
|
||
QSignalSpy doneSpy(load, &GridLoad::done);
|
||
load->abort();
|
||
a->fire(ok()); // 迟到
|
||
EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 注册 data 源 + 测试 + AUTOMOC**
|
||
|
||
`src/data/CMakeLists.txt`:源列表加 `api/DatasetLoadHandles.cpp`,末行 AUTOMOC 改 ON:
|
||
```cmake
|
||
add_library(geopro_data STATIC
|
||
parse/SampleParsers.cpp
|
||
repo/LocalSampleRepository.cpp
|
||
dto/NavDto.cpp
|
||
dto/DatasetChartDto.cpp
|
||
api/ApiProjectRepository.cpp
|
||
api/ApiDatasetRepository.cpp
|
||
api/DatasetLoadHandles.cpp)
|
||
...
|
||
set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)
|
||
```
|
||
`tests/CMakeLists.txt` data 段(`target_sources(... data/test_dataset_chart_dto.cpp)` 之后)加:
|
||
```cmake
|
||
target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp)
|
||
```
|
||
(data 测试段已 `target_link_libraries(... geopro_data)`;`geopro_data` PUBLIC 链 `geopro_net`,故 ApiBatch 头/符号可见。QSignalSpy 需 `Qt6::Test`——Task 3 已加到链接行。)
|
||
|
||
- [ ] **Step 7: 跑测试确认通过**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: 3 个 `DatasetLoadHandles.*` PASS;总 81/81。
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/data/api/DatasetLoads.hpp src/data/api/DatasetLoadHandles.hpp src/data/api/DatasetLoadHandles.cpp src/data/repo/IAsyncDatasetRepository.hpp src/data/CMakeLists.txt tests/data/test_dataset_load_handles.cpp tests/CMakeLists.txt
|
||
git commit -m "feat(data): 异步仓储接口 + ChartLoad/GridLoad 句柄(抽象基+Api实现) + 离线单测"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: data — `ApiDatasetRepository` 改实现 `IAsyncDatasetRepository`
|
||
|
||
**Files:**
|
||
- Modify: `src/data/api/ApiDatasetRepository.hpp`, `src/data/api/ApiDatasetRepository.cpp`
|
||
|
||
- [ ] **Step 1: 改 `ApiDatasetRepository.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include "repo/IAsyncDatasetRepository.hpp"
|
||
namespace geopro::net { class ApiClient; }
|
||
namespace geopro::data {
|
||
|
||
// 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。
|
||
class ApiDatasetRepository : public IAsyncDatasetRepository {
|
||
public:
|
||
explicit ApiDatasetRepository(net::ApiClient& api);
|
||
ChartLoad* loadChartAsync(const std::string& dsId) override;
|
||
GridLoad* loadGridAsync(const std::string& dsId) override;
|
||
private:
|
||
net::ApiClient& api_;
|
||
};
|
||
} // namespace geopro::data
|
||
```
|
||
|
||
- [ ] **Step 2: 改 `ApiDatasetRepository.cpp`**(保留 `enc`;`must` 改为复用 `isFailure` 谓词;用 getAsync/postJsonAsync + ApiBatch + ApiChartLoad/ApiGridLoad)
|
||
|
||
```cpp
|
||
#include "api/ApiDatasetRepository.hpp"
|
||
#include <stdexcept>
|
||
#include <QJsonObject>
|
||
#include <QString>
|
||
#include <QUrl>
|
||
#include "ApiClient.hpp"
|
||
#include "ApiBatch.hpp"
|
||
#include "api/DatasetLoadHandles.hpp"
|
||
#include "dto/DatasetChartDto.hpp"
|
||
|
||
namespace geopro::data {
|
||
namespace {
|
||
|
||
QString enc(const std::string& s) {
|
||
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
|
||
}
|
||
|
||
// 失败判定(原 must() 口径):业务码 != 200 或传输错误。
|
||
bool isFailure(const geopro::net::ApiResponse& r) {
|
||
return r.code != 200 || !r.rawError.isEmpty();
|
||
}
|
||
|
||
QJsonObject colorBody(const std::string& dsId, int type) {
|
||
return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
|
||
}
|
||
|
||
} // namespace
|
||
|
||
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
|
||
|
||
ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) {
|
||
// index 0 = scatter(GET),index 1 = 散点色阶 type1(POST)
|
||
QList<net::IApiCall*> calls{
|
||
api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))),
|
||
api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)),
|
||
};
|
||
auto* batch = new net::ApiBatch(calls, &isFailure);
|
||
return new ApiChartLoad(batch, [](const QList<net::ApiResponse>& r) {
|
||
ChartParts p;
|
||
p.scatter = dto::parseScatterGraph(r[0].data);
|
||
p.scatterScale = dto::parseColorBar(r[1].data);
|
||
return p;
|
||
});
|
||
}
|
||
|
||
GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) {
|
||
// index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET)
|
||
QList<net::IApiCall*> calls{
|
||
api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))),
|
||
api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)),
|
||
api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))),
|
||
};
|
||
auto* batch = new net::ApiBatch(calls, &isFailure);
|
||
return new ApiGridLoad(batch, [](const QList<net::ApiResponse>& r) {
|
||
GridParts p;
|
||
p.grid = dto::parseInversionGrid(r[0].data);
|
||
p.gridScale = dto::parseColorBar(r[1].data);
|
||
p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
|
||
return p;
|
||
});
|
||
}
|
||
|
||
} // namespace geopro::data
|
||
```
|
||
> 解析函数名/用法与原同步实现逐一对齐:`parseScatterGraph`/`parseColorBar`/`parseInversionGrid`/`parseDatasetAnomalies`(见原 ApiDatasetRepository.cpp)。`GridParts.grid` 由 `parseInversionGrid` 返回值覆盖(赋值即可,Grid 无默认构造但成员赋值用拷贝赋值合法)。
|
||
|
||
- [ ] **Step 3: 构建(真实 API 方法离线不可单测,验证编译/链接)**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
Expected: 链接通过。
|
||
> 真实端点行为由 Task 8 手动验证覆盖(与现有 ApiProjectRepository 同:无离线单测)。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/data/api/ApiDatasetRepository.hpp src/data/api/ApiDatasetRepository.cpp
|
||
git commit -m "feat(data): ApiDatasetRepository 改异步(loadChartAsync/loadGridAsync, ApiBatch+句柄, code==200判定)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: controller — `DatasetDetailController` 异步化(abort-and-replace + 身份比对)+ 改测试(TDD)
|
||
|
||
**Files:**
|
||
- Modify: `src/controller/DatasetDetailController.hpp`, `src/controller/DatasetDetailController.cpp`, `tests/controller/test_dataset_detail_controller.cpp`
|
||
|
||
- [ ] **Step 1: 改 `DatasetDetailController.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <string>
|
||
#include <QObject>
|
||
#include <QPointer>
|
||
#include <QString>
|
||
#include "model/Field.hpp"
|
||
#include "model/ColorScale.hpp"
|
||
#include "model/Anomaly.hpp"
|
||
namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; }
|
||
namespace geopro::controller {
|
||
|
||
class DatasetDetailController : public QObject {
|
||
Q_OBJECT
|
||
public:
|
||
enum class LoadPhase { Chart, Grid };
|
||
Q_ENUM(LoadPhase)
|
||
|
||
struct ChartData {
|
||
QString dsId, ddCode;
|
||
geopro::core::ScatterField scatter;
|
||
geopro::core::ColorScale scatterScale;
|
||
geopro::core::Grid grid{1, 1};
|
||
geopro::core::ColorScale gridScale;
|
||
std::vector<geopro::core::Anomaly> anomalies;
|
||
};
|
||
struct GridData {
|
||
QString dsId;
|
||
geopro::core::Grid grid{1, 1};
|
||
geopro::core::ColorScale gridScale;
|
||
std::vector<geopro::core::Anomaly> anomalies;
|
||
};
|
||
|
||
explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr);
|
||
public slots:
|
||
void openDataset(const QString& dsId, const QString& ddCode);
|
||
void focusDataset(const QString& dsId);
|
||
void loadGridData(const QString& dsId, const QString& ddCode);
|
||
signals:
|
||
void loadStarted(const QString& dsId, LoadPhase phase);
|
||
void chartReady(const ChartData& data);
|
||
void gridReady(const GridData& data);
|
||
void focusRequested(const QString& dsId);
|
||
void loadFailed(const QString& dsId, const QString& message);
|
||
private:
|
||
data::IAsyncDatasetRepository& repo_;
|
||
QPointer<data::ChartLoad> chartLoad_;
|
||
QPointer<data::GridLoad> gridLoad_;
|
||
};
|
||
} // namespace geopro::controller
|
||
```
|
||
|
||
- [ ] **Step 2: 改 `DatasetDetailController.cpp`**
|
||
|
||
```cpp
|
||
#include "DatasetDetailController.hpp"
|
||
#include "repo/IAsyncDatasetRepository.hpp"
|
||
#include "api/DatasetLoadHandles.hpp"
|
||
namespace geopro::controller {
|
||
|
||
DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent)
|
||
: QObject(parent), repo_(repo) {}
|
||
|
||
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) {
|
||
if (ddCode != QLatin1String("dd_inversion_data")) {
|
||
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
||
return;
|
||
}
|
||
if (chartLoad_) chartLoad_->abort(); // abort-and-replace
|
||
data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString());
|
||
chartLoad_ = load;
|
||
emit loadStarted(dsId, LoadPhase::Chart);
|
||
QObject::connect(load, &data::ChartLoad::done, this,
|
||
[this, load, dsId, ddCode](const data::ChartParts& parts) {
|
||
if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号
|
||
chartLoad_.clear();
|
||
ChartData d;
|
||
d.dsId = dsId;
|
||
d.ddCode = ddCode;
|
||
d.scatter = parts.scatter;
|
||
d.scatterScale = parts.scatterScale;
|
||
emit chartReady(d);
|
||
});
|
||
QObject::connect(load, &data::ChartLoad::failed, this,
|
||
[this, load, dsId](const QString& msg) {
|
||
if (load != chartLoad_) return;
|
||
chartLoad_.clear();
|
||
emit loadFailed(dsId, msg);
|
||
});
|
||
}
|
||
|
||
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) {
|
||
if (ddCode != QLatin1String("dd_inversion_data")) return;
|
||
if (gridLoad_) gridLoad_->abort();
|
||
data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString());
|
||
gridLoad_ = load;
|
||
emit loadStarted(dsId, LoadPhase::Grid);
|
||
QObject::connect(load, &data::GridLoad::done, this,
|
||
[this, load, dsId](const data::GridParts& parts) {
|
||
if (load != gridLoad_) return;
|
||
gridLoad_.clear();
|
||
GridData d;
|
||
d.dsId = dsId;
|
||
d.grid = parts.grid;
|
||
d.gridScale = parts.gridScale;
|
||
d.anomalies = parts.anomalies;
|
||
emit gridReady(d);
|
||
});
|
||
QObject::connect(load, &data::GridLoad::failed, this,
|
||
[this, load, dsId](const QString& msg) {
|
||
if (load != gridLoad_) return;
|
||
gridLoad_.clear();
|
||
emit loadFailed(dsId, msg);
|
||
});
|
||
}
|
||
|
||
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }
|
||
} // namespace geopro::controller
|
||
```
|
||
> 析构:`chartLoad_`/`gridLoad_` 是 `QPointer`,控制器析构时若句柄仍在飞,句柄自身在完成/abort 后 deleteLater;为稳妥可在析构中 abort(可选,Qt 退出会清理)。本期不显式写析构(栈析构逆序 ctrl→repo→api 已保证安全,见 spec §7)。
|
||
|
||
- [ ] **Step 3: 改测试 `tests/controller/test_dataset_detail_controller.cpp`**(异步 stub + 回灌防护)
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <QSignalSpy>
|
||
#include "DatasetDetailController.hpp"
|
||
#include "repo/IAsyncDatasetRepository.hpp"
|
||
#include "api/DatasetLoadHandles.hpp"
|
||
using namespace geopro;
|
||
|
||
namespace {
|
||
// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。
|
||
struct StubChartLoad : data::ChartLoad {
|
||
bool aborted = false;
|
||
void abort() override { aborted = true; }
|
||
void fireDone() { emit done(data::ChartParts{}); }
|
||
void fireFailed() { emit failed(QStringLiteral("x")); }
|
||
};
|
||
struct StubGridLoad : data::GridLoad {
|
||
bool aborted = false;
|
||
void abort() override { aborted = true; }
|
||
void fireDone() { emit done(data::GridParts{}); }
|
||
};
|
||
struct StubAsyncRepo : data::IAsyncDatasetRepository {
|
||
StubChartLoad* lastChart = nullptr;
|
||
StubGridLoad* lastGrid = nullptr;
|
||
data::ChartLoad* loadChartAsync(const std::string&) override {
|
||
lastChart = new StubChartLoad; return lastChart;
|
||
}
|
||
data::GridLoad* loadGridAsync(const std::string&) override {
|
||
lastGrid = new StubGridLoad; return lastGrid;
|
||
}
|
||
};
|
||
}
|
||
|
||
TEST(DatasetDetailController, EmitsChartReadyOnDone) {
|
||
StubAsyncRepo repo;
|
||
controller::DatasetDetailController c(repo);
|
||
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
|
||
c.openDataset("ds1", "dd_inversion_data");
|
||
repo.lastChart->fireDone();
|
||
EXPECT_EQ(spy.count(), 1);
|
||
}
|
||
|
||
TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
|
||
StubAsyncRepo repo;
|
||
controller::DatasetDetailController c(repo);
|
||
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
|
||
c.openDataset("ds1", "dd_inversion_data");
|
||
repo.lastChart->fireFailed();
|
||
EXPECT_EQ(spy.count(), 1);
|
||
}
|
||
|
||
TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) {
|
||
StubAsyncRepo repo;
|
||
controller::DatasetDetailController c(repo);
|
||
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
|
||
c.openDataset("ds1", "dd_other");
|
||
EXPECT_EQ(spy.count(), 1);
|
||
EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载
|
||
}
|
||
|
||
TEST(DatasetDetailController, AbortsPreviousOnReopen) {
|
||
StubAsyncRepo repo;
|
||
controller::DatasetDetailController c(repo);
|
||
c.openDataset("dsA", "dd_inversion_data");
|
||
StubChartLoad* a = repo.lastChart;
|
||
c.openDataset("dsB", "dd_inversion_data"); // 替换
|
||
EXPECT_TRUE(a->aborted); // 旧句柄被 abort
|
||
}
|
||
|
||
TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) {
|
||
StubAsyncRepo repo;
|
||
controller::DatasetDetailController c(repo);
|
||
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
|
||
c.openDataset("dsA", "dd_inversion_data");
|
||
StubChartLoad* a = repo.lastChart;
|
||
c.openDataset("dsB", "dd_inversion_data");
|
||
StubChartLoad* b = repo.lastChart;
|
||
a->fireDone(); // 旧句柄迟到 → 身份比对丢弃
|
||
EXPECT_EQ(spy.count(), 0);
|
||
b->fireDone(); // 当前句柄 → 正常
|
||
EXPECT_EQ(spy.count(), 1);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: 5 个 `DatasetDetailController.*` PASS(替换原 2 个);总 84/84。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/controller/DatasetDetailController.hpp src/controller/DatasetDetailController.cpp tests/controller/test_dataset_detail_controller.cpp
|
||
git commit -m "feat(controller): DatasetDetailController 异步化(abort-and-replace+句柄身份比对+loadStarted, 移除busy_/catch) + 回灌防护测试"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: app — 装配接线 + qRegisterMetaType
|
||
|
||
**Files:**
|
||
- Modify: `src/app/main.cpp`
|
||
|
||
- [ ] **Step 1: 注册元类型**(在 `main()` 早期、首次发起网络前,如 `QApplication app(...)` 之后)
|
||
|
||
在 main.cpp 顶部 include 区确保有 `#include "ApiClient.hpp"`(已有,经 ApiDatasetRepository)。在 `QApplication` 构造之后加:
|
||
|
||
```cpp
|
||
qRegisterMetaType<geopro::net::ApiResponse>();
|
||
```
|
||
|
||
- [ ] **Step 2: 确认装配无需改动**
|
||
|
||
`geopro::data::ApiDatasetRepository datasetRepo(api);`(main.cpp:847)与 `DatasetDetailController detailCtrl(datasetRepo);`(:848)——`datasetRepo` 现是 `IAsyncDatasetRepository`,控制器形参已改为该类型,**引用绑定无需改动**。第 505–527 行 chartReady/gridReady/loadFailed/focusRequested/gridDataNeeded 接线**全部不变**(信号面未变)。
|
||
|
||
- [ ] **Step 3: 构建 + 全量测试**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`
|
||
Expected: 84/84 PASS。构建出 `geopro_desktop.exe`。
|
||
|
||
- [ ] **Step 4: 手动验证(核心收益)**
|
||
|
||
启动 `build/release/src/app/geopro_desktop.exe`,登录 → 打开标准 ds `id=1458990939709440`:
|
||
- 双击数据集 → 原数据加载期间 **UI 不冻**(可拖窗口/切其他)。
|
||
- 切到「网格数据」页签 → rows 1–4s 加载期间 **UI 不冻**。
|
||
- rows 加载中**快速切换到另一数据集 / 关闭页签** → 旧请求被 abort(无旧数据回灌、无崩溃)。
|
||
- 并发:原数据 2 请求、网格 3 请求并发发出(网络面板可见)。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/main.cpp
|
||
git commit -m "feat(app): 装配异步详情仓储 + qRegisterMetaType<ApiResponse>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: UI — `LoadingOverlay` + 加载反馈(可后置/并行)
|
||
|
||
> 与异步内核无依赖,可在 Task 1–7 全绿后再做。先读 `src/app/panels/DatasetDetailPage.{hpp,cpp}` 确认 `gridView_` 容器与布局,再放遮罩。
|
||
|
||
**Files:**
|
||
- Create: `src/app/panels/LoadingOverlay.hpp`, `src/app/panels/LoadingOverlay.cpp`
|
||
- Modify: `src/app/panels/DatasetDetailPage.hpp`, `src/app/panels/DatasetDetailPage.cpp`, `src/app/panels/DatasetDetailPanel.hpp`, `src/app/panels/DatasetDetailPanel.cpp`, `src/app/main.cpp`, `src/app/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写 `LoadingOverlay.hpp`**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <QWidget>
|
||
class QLabel;
|
||
namespace geopro::app {
|
||
|
||
// 半透明「加载中…」遮罩。贴在目标视图上层,show()/hide() 切换,几何随父 resize 跟随。
|
||
class LoadingOverlay : public QWidget {
|
||
Q_OBJECT
|
||
public:
|
||
explicit LoadingOverlay(QWidget* parent);
|
||
void showOver(); // 提到父尺寸、置顶、显示
|
||
protected:
|
||
bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize
|
||
private:
|
||
QLabel* label_;
|
||
};
|
||
|
||
} // namespace geopro::app
|
||
```
|
||
|
||
- [ ] **Step 2: 写 `LoadingOverlay.cpp`**
|
||
|
||
```cpp
|
||
#include "panels/LoadingOverlay.hpp"
|
||
#include <QEvent>
|
||
#include <QLabel>
|
||
#include <QResizeEvent>
|
||
#include <QVBoxLayout>
|
||
|
||
namespace geopro::app {
|
||
|
||
LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) {
|
||
setAttribute(Qt::WA_StyledBackground, true);
|
||
setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);"));
|
||
label_->setText(QStringLiteral("加载中…"));
|
||
label_->setAlignment(Qt::AlignCenter);
|
||
auto* lay = new QVBoxLayout(this);
|
||
lay->addWidget(label_);
|
||
if (parent) parent->installEventFilter(this);
|
||
hide();
|
||
}
|
||
|
||
void LoadingOverlay::showOver() {
|
||
if (parentWidget()) setGeometry(parentWidget()->rect());
|
||
raise();
|
||
show();
|
||
}
|
||
|
||
bool LoadingOverlay::eventFilter(QObject* obj, QEvent* ev) {
|
||
if (obj == parentWidget() && ev->type() == QEvent::Resize && isVisible()) {
|
||
setGeometry(parentWidget()->rect());
|
||
}
|
||
return QWidget::eventFilter(obj, ev);
|
||
}
|
||
|
||
} // namespace geopro::app
|
||
```
|
||
|
||
- [ ] **Step 3: DatasetDetailPage 暴露加载状态接口**
|
||
|
||
`DatasetDetailPage.hpp` 加方法声明(`setGridData` 附近):
|
||
```cpp
|
||
void setGridLoading(bool on); // 网格视图遮罩开关
|
||
```
|
||
`DatasetDetailPage.cpp`:在构造里于 `gridView_` 之上建一个 `LoadingOverlay* gridOverlay_`(父为 gridView_ 的容器或 gridView_ 本身),实现:
|
||
```cpp
|
||
void DatasetDetailPage::setGridLoading(bool on) {
|
||
if (on) gridOverlay_->showOver(); else gridOverlay_->hide();
|
||
}
|
||
```
|
||
(成员加 `LoadingOverlay* gridOverlay_;`,头部 `#include "panels/LoadingOverlay.hpp"` 或前置声明 + cpp include。`setGridData` 末尾调 `setGridLoading(false)`。)
|
||
|
||
- [ ] **Step 4: DatasetDetailPanel 转发加载状态**
|
||
|
||
`DatasetDetailPanel.hpp` 加:
|
||
```cpp
|
||
void setGridLoading(const QString& dsId, bool on);
|
||
```
|
||
`DatasetDetailPanel.cpp` 实现:`pageFor(dsId)` 找到页 → `page->setGridLoading(on)`。
|
||
|
||
- [ ] **Step 5: main.cpp 接 loadStarted**
|
||
|
||
在第 518 行 gridReady 接线附近加:
|
||
```cpp
|
||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel,
|
||
[detailPanel](const QString& dsId, geopro::controller::DatasetDetailController::LoadPhase phase) {
|
||
if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid)
|
||
detailPanel->setGridLoading(dsId, true);
|
||
});
|
||
```
|
||
(原数据初次加载的 busy 反馈:页面于 chartReady 创建,加载期可设 `window.setCursor(Qt::BusyCursor)`,chartReady/loadFailed 复位——可选,最小化先不做。)
|
||
|
||
- [ ] **Step 6: 注册 LoadingOverlay 到 app CMake**
|
||
|
||
`src/app/CMakeLists.txt` 找到 panels 源列表,加 `panels/LoadingOverlay.cpp`(geopro app 目标已 AUTOMOC ON——确认;若否则比照现有 panel 文件加法)。
|
||
|
||
- [ ] **Step 7: 构建 + 手动验证遮罩**
|
||
|
||
Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
|
||
启动 app → 切网格页签:rows 加载期间网格区显示「加载中…」遮罩、到达后消失;切走/关页遮罩随页消失。
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/app/panels/LoadingOverlay.hpp src/app/panels/LoadingOverlay.cpp src/app/panels/DatasetDetailPage.hpp src/app/panels/DatasetDetailPage.cpp src/app/panels/DatasetDetailPanel.hpp src/app/panels/DatasetDetailPanel.cpp src/app/main.cpp src/app/CMakeLists.txt
|
||
git commit -m "feat(app): 网格懒加载「加载中」遮罩(LoadingOverlay) 接 loadStarted"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review(spec 覆盖核对)
|
||
|
||
- **§2 目标 1(不冻 UI)** → Task 2/5/7(getAsync 去 QEventLoop)+ Task 7 手动验证。✓
|
||
- **§2 目标 2(abort 过期)** → Task 2 ApiCall.abort / Task 3 ApiBatch.abort / Task 6 abort-and-replace + 身份比对。✓
|
||
- **§2 目标 3(并发)** → Task 5 ApiBatch 同时发 2/3 请求。✓
|
||
- **§2 目标 4(加载反馈)** → Task 6 loadStarted + Task 8 LoadingOverlay。✓
|
||
- **§2 目标 5(可复用原语)** → Task 2/3 IApiCall/ApiCall/ApiBatch。✓
|
||
- **§2 目标 6(测试不退化、离线可测)** → Task 1/3/4/6 单测,全程 dev-test。✓
|
||
- **§5.0 安全不变量** → 各层 `aborted_`(ApiCall/ApiBatch/ApiChartLoad/ApiGridLoad)+ 控制器身份比对 + 全 deleteLater;回归用例 Task 3 AbortGate / Task 4 GridLoadAbort / Task 6 DropsLateSignal。✓
|
||
- **§5.1 buildResponse 复用** → Task 1。✓
|
||
- **§7 失败判定 code==200** → Task 5 isFailure。✓
|
||
- **§5.2 旧方法去向 / 不碰 LocalSampleRepository** → Task 5 仅改 ApiDatasetRepository,IDatasetRepository 不动。✓
|
||
- **§5.2 fail-fast** → Task 3 FailFastAbortsOthers。✓
|
||
- **AUTOMOC** → Task 2(net ON)/ Task 4(data ON)。✓
|
||
- **qRegisterMetaType** → Task 7。✓
|
||
|
||
类型/签名一致性核对:`IApiCall::finished(ApiResponse)`、`ApiBatch::succeeded(QList<ApiResponse>)`/`failed(int,ApiResponse)`、`ChartLoad::done(ChartParts)`/`failed(QString)`、`IAsyncDatasetRepository::loadChartAsync→ChartLoad*`、控制器 `loadStarted(QString,LoadPhase)` 全程一致。
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
计划已保存 `docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`。两种执行方式:
|
||
|
||
1. **Subagent-Driven(推荐)** — 每个 Task 派新 subagent 实现,任务间审查,迭代快。
|
||
2. **Inline Execution** — 本会话内按 executing-plans 批量执行 + 检查点。
|
||
|
||
选哪个?
|
||
</content>
|