feat(data+controller): ApiDatasetRepository 改异步 + DatasetDetailController abort-and-replace+句柄身份比对+loadStarted(移除 busy_/catch)+ 回灌防护测试

This commit is contained in:
gaozheng 2026-06-11 20:37:10 +08:00
parent 8cdd6679a9
commit e57985c057
5 changed files with 162 additions and 99 deletions

View File

@ -1,60 +1,62 @@
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include <stdexcept> #include "repo/IAsyncDatasetRepository.hpp"
#include "repo/IDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp"
namespace geopro::controller { namespace geopro::controller {
DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent) DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent)
: QObject(parent), repo_(repo) {} : QObject(parent), repo_(repo) {}
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) {
if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入)
busy_ = true;
if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演
busy_ = false;
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
return; return;
} }
const std::string id = dsId.toStdString(); if (chartLoad_) chartLoad_->abort(); // abort-and-replace
try { 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; ChartData d;
d.dsId = dsId; d.dsId = dsId;
d.ddCode = ddCode; d.ddCode = ddCode;
// 严格对齐原版「原数据」页:只拉 scatter + 散点色阶(type1) 两个。 d.scatter = parts.scatter;
// 网格数据(inversion/rows)与异常(queryException)随「网格数据」页按需懒加载。 d.scatterScale = parts.scatterScale;
d.scatter = repo_.loadScatter(id);
d.scatterScale = repo_.loadScatterColorScale(id);
busy_ = false;
emit chartReady(d); emit chartReady(d);
} catch (const std::exception& e) { });
busy_ = false; QObject::connect(load, &data::ChartLoad::failed, this,
emit loadFailed(dsId, QString::fromStdString(e.what())); [this, load, dsId](const QString& msg) {
} catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_否则永久拒绝后续加载 if (load != chartLoad_) return;
busy_ = false; chartLoad_.clear();
emit loadFailed(dsId, QStringLiteral("未知错误")); emit loadFailed(dsId, msg);
} });
} }
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) {
if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入)
if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据
busy_ = true; if (gridLoad_) gridLoad_->abort(); // abort-and-replace
const std::string id = dsId.toStdString(); data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString());
try { 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; // §5.0 句柄身份比对:丢弃迟到信号
gridLoad_.clear();
GridData d; GridData d;
d.dsId = dsId; d.dsId = dsId;
// 网格数据rows(服务端网格化,慢) + 色阶 type2 + 异常 queryException。 d.grid = parts.grid;
d.grid = repo_.loadGrid(id); d.gridScale = parts.gridScale;
d.gridScale = repo_.loadColorScale(id); d.anomalies = parts.anomalies;
d.anomalies = repo_.loadAnomalies(id);
busy_ = false;
emit gridReady(d); emit gridReady(d);
} catch (const std::exception& e) { });
busy_ = false; QObject::connect(load, &data::GridLoad::failed, this,
emit loadFailed(dsId, QString::fromStdString(e.what())); [this, load, dsId](const QString& msg) {
} catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_否则永久拒绝后续加载 if (load != gridLoad_) return;
busy_ = false; gridLoad_.clear();
emit loadFailed(dsId, QStringLiteral("未知错误")); emit loadFailed(dsId, msg);
} });
} }
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }

View File

@ -1,18 +1,21 @@
#pragma once #pragma once
#include <string> #include <string>
#include <QObject> #include <QObject>
#include <QPointer>
#include <QString> #include <QString>
#include "model/Field.hpp" #include "model/Field.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
namespace geopro::data { class IDatasetRepository; } namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; }
namespace geopro::controller { namespace geopro::controller {
// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。 // 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。
// 仅服务图表,不与 WorkbenchNavController项目/结构导航)耦合。
class DatasetDetailController : public QObject { class DatasetDetailController : public QObject {
Q_OBJECT Q_OBJECT
public: public:
enum class LoadPhase { Chart, Grid };
Q_ENUM(LoadPhase)
struct ChartData { struct ChartData {
QString dsId, ddCode; QString dsId, ddCode;
geopro::core::ScatterField scatter; geopro::core::ScatterField scatter;
@ -29,19 +32,20 @@ public:
std::vector<geopro::core::Anomaly> anomalies; std::vector<geopro::core::Anomaly> anomalies;
}; };
explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr); explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr);
public slots: public slots:
void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页 void openDataset(const QString& dsId, const QString& ddCode);
void focusDataset(const QString& dsId); // 单击=聚焦已开页 void focusDataset(const QString& dsId);
// 网格数据懒加载网格页首次激活时调用rows 服务端网格化 1-4s故不随 openDataset 拉)。
void loadGridData(const QString& dsId, const QString& ddCode); void loadGridData(const QString& dsId, const QString& ddCode);
signals: signals:
void loadStarted(const QString& dsId, LoadPhase phase);
void chartReady(const ChartData& data); void chartReady(const ChartData& data);
void gridReady(const GridData& data); void gridReady(const GridData& data);
void focusRequested(const QString& dsId); void focusRequested(const QString& dsId);
void loadFailed(const QString& dsId, const QString& message); void loadFailed(const QString& dsId, const QString& message);
private: private:
data::IDatasetRepository& repo_; data::IAsyncDatasetRepository& repo_;
bool busy_ = false; QPointer<data::ChartLoad> chartLoad_;
QPointer<data::GridLoad> gridLoad_;
}; };
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -1,51 +1,62 @@
#include "api/ApiDatasetRepository.hpp" #include "api/ApiDatasetRepository.hpp"
#include <stdexcept>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "ApiBatch.hpp"
#include "api/DatasetLoadHandles.hpp"
#include "dto/DatasetChartDto.hpp" #include "dto/DatasetChartDto.hpp"
namespace geopro::data { namespace geopro::data {
namespace { namespace {
QString enc(const std::string& s) { QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
} }
void must(const net::ApiResponse& r, const char* what) {
if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " + // 失败判定(原 must() 口径):业务码 != 200 或传输错误。
(r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString())); bool isFailure(const geopro::net::ApiResponse& r) {
return r.code != 200 || !r.rawError.isEmpty();
} }
geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) {
QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; QJsonObject colorBody(const std::string& dsId, int type) {
const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body); return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
must(r, "colorGradation");
return dto::parseColorBar(r.data);
} }
} // namespace } // namespace
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) { ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) {
const net::ApiResponse r = api_.get( // index 0 = scatter(GET)index 1 = 散点色阶 type1(POST)
QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))); QList<net::IApiCall*> calls{
must(r, "inversion/rows"); api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))),
return dto::parseInversionGrid(r.data); 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;
});
} }
geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) {
const net::ApiResponse r = api_.get( GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) {
QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))); // index 0 = rows(GET,慢)1 = 色阶 type2(POST)2 = 异常(GET)
must(r, "scatterGraph"); QList<net::IApiCall*> calls{
return dto::parseScatterGraph(r.data); api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))),
} api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)),
geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) { api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))),
return colorScale(api_, dsId, 2); };
} auto* batch = new net::ApiBatch(calls, &isFailure);
geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) { return new ApiGridLoad(batch, [](const QList<net::ApiResponse>& r) {
return colorScale(api_, dsId, 1); GridParts p;
} p.grid = dto::parseInversionGrid(r[0].data);
std::vector<geopro::core::Anomaly> ApiDatasetRepository::loadAnomalies(const std::string& dsId) { p.gridScale = dto::parseColorBar(r[1].data);
const net::ApiResponse r = api_.get( p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))); return p;
must(r, "queryException"); });
return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray());
} }
} // namespace geopro::data } // namespace geopro::data

View File

@ -1,18 +1,14 @@
#pragma once #pragma once
#include "repo/IDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
namespace geopro::net { class ApiClient; } namespace geopro::net { class ApiClient; }
namespace geopro::data { namespace geopro::data {
// 真实 API 实现 IDatasetRepositoryERT 反演相关)。失败抛 std::runtime_error // 真实 API 实现 IAsyncDatasetRepositoryERT 反演)。每次加载返回自管理句柄
class ApiDatasetRepository : public IDatasetRepository { class ApiDatasetRepository : public IAsyncDatasetRepository {
public: public:
explicit ApiDatasetRepository(net::ApiClient& api); explicit ApiDatasetRepository(net::ApiClient& api);
std::vector<GsNode> loadStructure() override { return {}; } // 不经此仓储取结构 ChartLoad* loadChartAsync(const std::string& dsId) override;
geopro::core::Grid loadGrid(const std::string& dsId) override; GridLoad* loadGridAsync(const std::string& dsId) override;
geopro::core::ScatterField loadScatter(const std::string& dsId) override;
geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格
geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据
std::vector<geopro::core::Anomaly> loadAnomalies(const std::string& dsId) override;
private: private:
net::ApiClient& api_; net::ApiClient& api_;
}; };

View File

@ -1,31 +1,81 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <QSignalSpy> #include <QSignalSpy>
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "repo/IDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
#include "api/DatasetLoadHandles.hpp"
using namespace geopro; using namespace geopro;
namespace { namespace {
struct StubRepo : data::IDatasetRepository { // 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。
bool fail = false; struct StubChartLoad : data::ChartLoad {
std::vector<data::GsNode> loadStructure() override { return {}; } bool aborted = false;
core::Grid loadGrid(const std::string&) override { core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; } void abort() override { aborted = true; }
// openDataset 现只拉 scatter/scatterScale/anomalies网格懒加载失败路径由 loadScatter 抛出触发。 void fireDone() { emit done(data::ChartParts{}); }
core::ScatterField loadScatter(const std::string&) override { if (fail) throw std::runtime_error("x"); return {}; } void fireFailed() { emit failed(QStringLiteral("x")); }
core::ColorScale loadColorScale(const std::string&) override { return {}; } };
core::ColorScale loadScatterColorScale(const std::string&) override { return {}; } struct StubGridLoad : data::GridLoad {
std::vector<core::Anomaly> loadAnomalies(const std::string&) override { return {}; } 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, EmitsChartReadyOnSuccess) {
StubRepo repo; TEST(DatasetDetailController, EmitsChartReadyOnDone) {
StubAsyncRepo repo;
controller::DatasetDetailController c(repo); controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
c.openDataset("ds1", "dd_inversion_data"); c.openDataset("ds1", "dd_inversion_data");
repo.lastChart->fireDone();
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
} }
TEST(DatasetDetailController, EmitsLoadFailedOnThrow) {
StubRepo repo; repo.fail = true; TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
StubAsyncRepo repo;
controller::DatasetDetailController c(repo); controller::DatasetDetailController c(repo);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data"); 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); EXPECT_EQ(spy.count(), 1);
} }