diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index e9435af..8843727 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -275,6 +275,7 @@ void WorkbenchNavController::loadMoreData() { QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { if (req != moreDataReq_) return; moreDataReq_.clear(); + --dataPageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreData 跳页 emit loadFailed(QStringLiteral("datasets"), msg); emitBusyIfChanged(); }); @@ -299,6 +300,7 @@ void WorkbenchNavController::loadMoreFiles() { QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { if (req != moreFilesReq_) return; moreFilesReq_.clear(); + --filePageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreFiles 跳页 emit loadFailed(QStringLiteral("files"), msg); emitBusyIfChanged(); }); @@ -331,13 +333,22 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { if (h) h->abort(); checkedInflight_.clear(); - QStringList missing; + // 入口去重:保留首次出现顺序,剔除重复 id(避免重复请求 + 异常树重复 group)。 + QStringList deduped; + QSet seen; for (const QString& tmQ : tmObjectIds) { + if (seen.contains(tmQ)) continue; + seen.insert(tmQ); + deduped.push_back(tmQ); + } + + QStringList missing; + for (const QString& tmQ : deduped) { const std::string tm = tmQ.toStdString(); if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ); } if (missing.isEmpty()) { // 全命中缓存:同步组装,busyChanged 不抖动 - assembleAndEmitExceptionTree(tmObjectIds); + assembleAndEmitExceptionTree(deduped); emitBusyIfChanged(); return; } @@ -350,7 +361,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { NavRequest* req = repo_.loadExceptionsByTmAsync(tm); checkedInflight_.push_back(req); QObject::connect(req, &NavRequest::done, this, - [this, req, tm, remaining, failedFlag, tmObjectIds](const QVariant& v) { + [this, req, tm, remaining, failedFlag, deduped](const QVariant& v) { // 身份比对:req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。 bool inCurrent = false; for (const auto& h : checkedInflight_) @@ -359,7 +370,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { tmExceptionCache_[tm] = qvariant_cast>(v); if (--(*remaining) == 0 && !*failedFlag) { checkedInflight_.clear(); - assembleAndEmitExceptionTree(tmObjectIds); + assembleAndEmitExceptionTree(deduped); emitBusyIfChanged(); } }); diff --git a/src/data/api/NavRequest.hpp b/src/data/api/NavRequest.hpp index 9859275..173c007 100644 --- a/src/data/api/NavRequest.hpp +++ b/src/data/api/NavRequest.hpp @@ -27,7 +27,7 @@ public: using Parser = std::function; using Predicate = std::function; ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, - QObject* parent = nullptr); // 接管 call + QObject* parent = nullptr); // 持有非拥有引用(QPointer);call 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它 void abort() override; private: QPointer call_; diff --git a/src/net/ApiBatch.hpp b/src/net/ApiBatch.hpp index 5c7190c..da17a40 100644 --- a/src/net/ApiBatch.hpp +++ b/src/net/ApiBatch.hpp @@ -13,7 +13,7 @@ class ApiBatch : public QObject { Q_OBJECT public: using Predicate = std::function; - ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls + ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 持有非拥有引用(QPointer 列表);各 call 完成或 abort 后自行 deleteLater,本类不得 delete 它们 void abort(); signals: void succeeded(const QList& responses); diff --git a/src/net/ApiChain.hpp b/src/net/ApiChain.hpp index 8575b87..f0b3f4c 100644 --- a/src/net/ApiChain.hpp +++ b/src/net/ApiChain.hpp @@ -11,10 +11,12 @@ namespace geopro::net { // 任一步失败 → fail-fast:failed(index,resp) + abort 当前在飞 + deleteLater。 // 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 deleteLater)。 // 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 +// 注意:Part A(导航)暂未接入生产调用,首个生产使用见 Part B 登录(AuthService 串行链); +// 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。 class ApiChain : public QObject { Q_OBJECT public: - // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(接管所有权)。可抛 std::exception。 + // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall(持有非拥有引用 QPointer;IApiCall 完成或 abort 后自行 deleteLater,本类不得 delete 它)。可抛 std::exception。 using StepFactory = std::function& prior)>; using Predicate = std::function; ApiChain(QList steps, Predicate isFailure, QObject* parent = nullptr); diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp index 5336266..6a60b2e 100644 --- a/tests/controller/test_workbench_nav_controller.cpp +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -188,6 +188,26 @@ TEST(WorkbenchNavController, SelectDatasetEmitsDetail) { EXPECT_EQ(spy.count(), 1); } +// selectObject 三并发部分失败:data 失败、file/detail 成功 → loadFailed×1,filesLoaded×1,objectDetailLoaded×1,datasetsLoaded×0。 +TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) { + StubAsyncRepo repo; + controller::WorkbenchNavController c(repo); + QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded); + QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded); + QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded); + QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed); + c.selectObject("obj2", 1); + // data 路失败,file/detail 路成功(三路独立,互不影响) + repo.lastData->fireFailed(); + repo.lastFile->fireDone(dsPageVar()); + repo.lastDetail->fireDone(formVar()); + EXPECT_EQ(dsSpy.count(), 0); // data 失败,无 datasetsLoaded + EXPECT_EQ(flSpy.count(), 1); // file 成功,有 filesLoaded + EXPECT_EQ(dtSpy.count(), 1); // detail 成功,有 objectDetailLoaded + EXPECT_EQ(failSpy.count(), 1); // 只有 data 路触发 loadFailed + EXPECT_EQ(failSpy.first().at(0).toString(), QStringLiteral("datasets")); +} + // 失败路径:start 首级失败 → loadFailed + busy 复位。 TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { StubAsyncRepo repo;