harden(controller+net): setCheckedTms 去重 + loadMore 失败回滚页号 + 非拥有所有权注释更正 + ApiChain 待用注释 + selectObject 部分失败测试(Part A 评审 I-2/I-3/I-4/M-1/M-4)

This commit is contained in:
gaozheng 2026-06-12 08:04:08 +08:00
parent b097fa6e56
commit 62352395ba
5 changed files with 40 additions and 7 deletions

View File

@ -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<QString> 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<std::vector<ExceptionRow>>(v);
if (--(*remaining) == 0 && !*failedFlag) {
checkedInflight_.clear();
assembleAndEmitExceptionTree(tmObjectIds);
assembleAndEmitExceptionTree(deduped);
emitBusyIfChanged();
}
});

View File

@ -27,7 +27,7 @@ public:
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
QObject* parent = nullptr); // 接管 call
QObject* parent = nullptr); // 持有非拥有引用QPointercall 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它
void abort() override;
private:
QPointer<geopro::net::IApiCall> call_;

View File

@ -13,7 +13,7 @@ class ApiBatch : public QObject {
Q_OBJECT
public:
using Predicate = std::function<bool(const ApiResponse&)>;
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 持有非拥有引用QPointer 列表);各 call 完成或 abort 后自行 deleteLater本类不得 delete 它们
void abort();
signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses);

View File

@ -11,10 +11,12 @@ namespace geopro::net {
// 任一步失败 → fail-fastfailed(index,resp) + abort 当前在飞 + deleteLater。
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0aborted_ 闸门 + 一律 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持有非拥有引用 QPointerIApiCall 完成或 abort 后自行 deleteLater本类不得 delete 它)。可抛 std::exception。
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
using Predicate = std::function<bool(const ApiResponse&)>;
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);

View File

@ -188,6 +188,26 @@ TEST(WorkbenchNavController, SelectDatasetEmitsDetail) {
EXPECT_EQ(spy.count(), 1);
}
// selectObject 三并发部分失败data 失败、file/detail 成功 → loadFailed×1filesLoaded×1objectDetailLoaded×1datasetsLoaded×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;