From 72b300d722ab31ab107c00e06f4e4881b7c6cb1b Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 20:05:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(net):=20ApiBatch=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=B1=87=E8=81=9A+fail-fast+abort=E9=97=B8=E9=97=A8=20+=20?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/net/ApiBatch.cpp | 41 +++++++++++++++++++++++++++ src/net/ApiBatch.hpp | 29 +++++++++++++++++++ src/net/CMakeLists.txt | 1 + tests/CMakeLists.txt | 8 ++++-- tests/net/FakeApiCall.hpp | 15 ++++++++++ tests/net/test_api_batch.cpp | 54 ++++++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/net/ApiBatch.cpp create mode 100644 src/net/ApiBatch.hpp create mode 100644 tests/net/FakeApiCall.hpp create mode 100644 tests/net/test_api_batch.cpp diff --git a/src/net/ApiBatch.cpp b/src/net/ApiBatch.cpp new file mode 100644 index 0000000..65c3b18 --- /dev/null +++ b/src/net/ApiBatch.cpp @@ -0,0 +1,41 @@ +#include "ApiBatch.hpp" + +namespace geopro::net { + +ApiBatch::ApiBatch(QList calls, Predicate isFailure, QObject* parent) + : QObject(parent), isFailure_(std::move(isFailure)) { + responses_.resize(calls.size()); + remaining_ = static_cast(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 diff --git a/src/net/ApiBatch.hpp b/src/net/ApiBatch.hpp new file mode 100644 index 0000000..5c7190c --- /dev/null +++ b/src/net/ApiBatch.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include +#include +#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; + ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls + void abort(); +signals: + void succeeded(const QList& responses); + void failed(int index, const geopro::net::ApiResponse& resp); +private: + QList> calls_; + QList responses_; + Predicate isFailure_; + int remaining_ = 0; + bool aborted_ = false; +}; + +} // namespace geopro::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index d608c7b..64c9341 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(geopro_net STATIC ApiResponseParse.cpp IApiCall.cpp ApiCall.cpp + ApiBatch.cpp AuthService.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f999606..c5a1e21 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -43,11 +43,13 @@ target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package # 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。 find_package(OpenSSL REQUIRED) -find_package(Qt6 COMPONENTS Core Network REQUIRED) +find_package(Qt6 COMPONENTS Core Network Test REQUIRED) target_sources(geopro_tests PRIVATE net/test_rsa.cpp) # 端到端登录连通测试(ApiClient + AuthService),需 Qt6::Core/Network 与事件循环。 target_sources(geopro_tests PRIVATE net/test_auth.cpp) -target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) +# ApiBatch 离线单测(QSignalSpy 需 Qt6::Test)。 +target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) # geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时 # DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。 @@ -87,6 +89,8 @@ if(TARGET qwt) endif() target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app) +# tests/ 在 include 路径:net/test_api_batch.cpp 用 #include "net/FakeApiCall.hpp"。 +target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests) target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp) # ColorMapService 测试(geopro_desktop 是可执行文件,直接把源加入测试目标) target_sources(geopro_tests PRIVATE diff --git a/tests/net/FakeApiCall.hpp b/tests/net/FakeApiCall.hpp new file mode 100644 index 0000000..71b48a4 --- /dev/null +++ b/tests/net/FakeApiCall.hpp @@ -0,0 +1,15 @@ +#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 diff --git a/tests/net/test_api_batch.cpp b/tests/net/test_api_batch.cpp new file mode 100644 index 0000000..8f3e9ab --- /dev/null +++ b/tests/net/test_api_batch.cpp @@ -0,0 +1,54 @@ +#include +#include +#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); +}